Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions atlassian/confluence/cloud/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,8 +592,11 @@ def search(
"""
Search for content in Confluence.

Confluence Cloud offers CQL search only on the v1 API, so this call is made
against the v1 search endpoint.

Args:
query: Text to search for
query: Text to search for. It is sent as a ``text ~`` CQL clause
cql: (optional) Confluence Query Language (CQL) expression to filter by
cursor: (optional) Cursor to start searching from for pagination
limit: (optional) Maximum number of results to return per request. Default: 25
Expand Down Expand Up @@ -650,6 +653,15 @@ def cql_all(self, *args, **kwargs) -> List[Dict[str, Any]]:
"""
return list(self.iter_cql(*args, **kwargs))

@staticmethod
def _add_text_clause(cql: str, query: str) -> str:
"""Add a ``text ~`` clause for *query* to *cql*, keeping ORDER BY last."""
text_clause = 'text ~ "{}"'.format(query.replace('"', '\\"'))
order_by = re.search(r"\border\s+by\b", cql, re.IGNORECASE)
if order_by:
return f"{cql[: order_by.start()].rstrip()} AND {text_clause} {cql[order_by.start() :]}"
return f"{cql} AND {text_clause}"

def _search_params(
self,
query: str = "",
Expand All @@ -658,25 +670,27 @@ def _search_params(
excerpt: bool = True,
body_format: Optional[str] = None,
) -> Dict[str, Any]:
"""Build query parameters for the V2 search endpoint.
"""
Build query parameters for the v1 CQL search endpoint.

Raises:
ValueError: If neither *query* nor *cql* is provided, or an
invalid *body_format* is given.
The endpoint takes a CQL expression only, so a free text *query* becomes a
``text ~`` clause and *body_format* becomes the matching ``expand`` value.
A ValueError is raised when neither *query* nor *cql* is given, or when
*body_format* is not 'view', 'storage' or 'atlas_doc_format'.
"""
if not query and not cql:
raise ValueError("Either 'query' or 'cql' must be provided")
params: Dict[str, Any] = {"limit": limit}
if query:
params["query"] = query
if cql:
params["cql"] = cql
if cql and query:
cql = self._add_text_clause(cql, query)
elif query:
cql = 'text ~ "{}"'.format(query.replace('"', '\\"'))
params: Dict[str, Any] = {"limit": limit, "cql": cql}
if not excerpt:
params["excerpt"] = "false"
params["excerpt"] = "none"
if body_format:
if body_format not in ("view", "storage", "atlas_doc_format"):
raise ValueError("body_format must be one of 'view', 'storage', or 'atlas_doc_format'")
params["body-format"] = body_format
params["expand"] = f"content.body.{body_format}"
return params

def search_content(
Expand Down
4 changes: 3 additions & 1 deletion atlassian/confluence_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ class ConfluenceEndpoints:
"child_pages": "api/v2/pages/{id}/direct-children",
"page_versions": "api/v2/pages/{id}/versions",
"page_version": "api/v2/pages/{id}/versions/{version_number}",
"search": "api/v2/search",
# Confluence Cloud has no CQL search endpoint in v2, so searching keeps
# using the v1 API.
"search": "rest/api/search",
"spaces": "api/v2/spaces",
"space_by_id": "api/v2/spaces/{id}",
"page_properties": "api/v2/pages/{id}/properties",
Expand Down
3 changes: 2 additions & 1 deletion docs/confluence.rst
Original file line number Diff line number Diff line change
Expand Up @@ -965,7 +965,8 @@ CQL
``iter_cql()`` and ``cql_all()`` work for Cloud and Server/Data Center. Cloud
follows its cursor link, while Server/Data Center retains its offset-based
pagination semantics. The Cloud V2 client (``ConfluenceV2``) exposes the same
pair on the ``api/v2/search`` endpoint:
pair. CQL search has no v2 endpoint, so it is served by ``rest/api/search``
there as well:

.. code-block:: python

Expand Down
22 changes: 20 additions & 2 deletions tests/confluence/test_confluence_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -781,14 +781,32 @@ def test_iter_cql_follows_all_result_pages(self, mock_get_paged, confluence_v2):
mock_get_paged.return_value = iter([{"id": "1"}, {"id": "2"}])

assert list(confluence_v2.iter_cql('type = "page"', limit=25)) == [{"id": "1"}, {"id": "2"}]
mock_get_paged.assert_called_once_with("api/v2/search", params={"cql": 'type = "page"', "limit": 25})
mock_get_paged.assert_called_once_with("rest/api/search", params={"cql": 'type = "page"', "limit": 25})

@patch.object(ConfluenceCloudV2, "_get_paged")
def test_iter_cql_supports_text_query(self, mock_get_paged, confluence_v2):
mock_get_paged.return_value = iter([{"id": "1"}])

assert list(confluence_v2.iter_cql(query="meeting notes", limit=10)) == [{"id": "1"}]
mock_get_paged.assert_called_once_with("api/v2/search", params={"query": "meeting notes", "limit": 10})
mock_get_paged.assert_called_once_with("rest/api/search", params={"cql": 'text ~ "meeting notes"', "limit": 10})

@patch.object(ConfluenceCloudV2, "_get_paged")
def test_iter_cql_combines_cql_and_text_query_before_order_by(self, mock_get_paged, confluence_v2):
mock_get_paged.return_value = iter([])

list(confluence_v2.iter_cql(cql='space.id = "42" order by lastmodified desc', query="notes"))

assert mock_get_paged.call_args.kwargs["params"]["cql"] == (
'space.id = "42" AND text ~ "notes" order by lastmodified desc'
)

@patch.object(ConfluenceCloudV2, "_get_paged")
def test_iter_cql_maps_body_format_to_expand(self, mock_get_paged, confluence_v2):
mock_get_paged.return_value = iter([])

list(confluence_v2.iter_cql('type = "page"', body_format="storage"))

assert mock_get_paged.call_args.kwargs["params"]["expand"] == "content.body.storage"

@patch.object(ConfluenceCloudV2, "_get_paged")
def test_iter_cql_requires_query_or_cql(self, mock_get_paged, confluence_v2):
Expand Down
6 changes: 3 additions & 3 deletions tests/test_confluence_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,8 @@ def test_search(self, mock_get):
# Call the method with just query
response = self.confluence_v2.search("test query")

# Assertions
mock_get.assert_called_once_with("api/v2/search", params={"limit": 25, "query": "test query"})
# Assertions: CQL search only exists on v1, and a text query becomes a CQL clause
mock_get.assert_called_once_with("rest/api/search", params={"limit": 25, "cql": 'text ~ "test query"'})
self.assertEqual(response, mock_response)

@patch("atlassian.confluence.cloud.ConfluenceCloud.get")
Expand All @@ -489,7 +489,7 @@ def test_search_with_cql(self, mock_get):

# Assertions
mock_get.assert_called_once_with(
"api/v2/search", params={"limit": 10, "cql": "type = 'page' AND space.id = '123'", "excerpt": "false"}
"rest/api/search", params={"limit": 10, "cql": "type = 'page' AND space.id = '123'", "excerpt": "none"}
)
self.assertEqual(response, mock_response)

Expand Down