From 2c3f51e766a3eb7871348d4dc52c45231906f397 Mon Sep 17 00:00:00 2001 From: R3gardless <48237469+R3gardless@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:24:01 +0900 Subject: [PATCH 1/3] Confluence Cloud: run CQL search against the v1 endpoint The Confluence Cloud v2 API has no search endpoint, so search(), iter_cql(), cql_all(), search_content() and get_space_content() were all calling api/v2/search, which does not exist. Point the search endpoint at v1 rest/api/search and translate the arguments to what it accepts: a free text query becomes a text ~ CQL clause (added before a trailing ORDER BY when a CQL expression is given too), excerpt=False becomes excerpt=none and body_format becomes expand=content.body.. --- atlassian/confluence/cloud/cloud.py | 33 ++++++++++++++++------- atlassian/confluence_base.py | 4 ++- tests/confluence/test_confluence_cloud.py | 22 +++++++++++++-- tests/test_confluence_v2.py | 6 ++--- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/atlassian/confluence/cloud/cloud.py b/atlassian/confluence/cloud/cloud.py index 860f82806..5c6bc997c 100644 --- a/atlassian/confluence/cloud/cloud.py +++ b/atlassian/confluence/cloud/cloud.py @@ -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 @@ -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 = "", @@ -658,7 +670,10 @@ 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. + + The endpoint takes a CQL expression only, so a free text *query* becomes a + ``text ~`` clause and *body_format* becomes the matching ``expand`` value. Raises: ValueError: If neither *query* nor *cql* is provided, or an @@ -666,17 +681,17 @@ def _search_params( """ 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( diff --git a/atlassian/confluence_base.py b/atlassian/confluence_base.py index 59cca8b0f..8ee264886 100644 --- a/atlassian/confluence_base.py +++ b/atlassian/confluence_base.py @@ -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", diff --git a/tests/confluence/test_confluence_cloud.py b/tests/confluence/test_confluence_cloud.py index 595b3aaaa..0991474ae 100644 --- a/tests/confluence/test_confluence_cloud.py +++ b/tests/confluence/test_confluence_cloud.py @@ -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): diff --git a/tests/test_confluence_v2.py b/tests/test_confluence_v2.py index 9036cb849..99a5b7461 100644 --- a/tests/test_confluence_v2.py +++ b/tests/test_confluence_v2.py @@ -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") @@ -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) From dea43fe37621c12adf296a43804bf5d311ce8629 Mon Sep 17 00:00:00 2001 From: R3gardless <48237469+R3gardless@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:32:53 +0900 Subject: [PATCH 2/3] Keep the _search_params docstring free of pydocstyle findings Codacy's Prospector run flags the summary position and the Google style Raises section, so state the error behaviour in prose instead. --- atlassian/confluence/cloud/cloud.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/atlassian/confluence/cloud/cloud.py b/atlassian/confluence/cloud/cloud.py index 5c6bc997c..ce2e150c8 100644 --- a/atlassian/confluence/cloud/cloud.py +++ b/atlassian/confluence/cloud/cloud.py @@ -670,14 +670,13 @@ def _search_params( excerpt: bool = True, body_format: Optional[str] = None, ) -> Dict[str, Any]: - """Build query parameters for the v1 CQL search endpoint. + """ + Build query parameters for the v1 CQL search endpoint. The endpoint takes a CQL expression only, so a free text *query* becomes a ``text ~`` clause and *body_format* becomes the matching ``expand`` value. - - Raises: - ValueError: If neither *query* nor *cql* is provided, or an - invalid *body_format* is given. + 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") From 792601b74d7803e047b1973f0888ccb89a3c611a Mon Sep 17 00:00:00 2001 From: R3gardless <48237469+R3gardless@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:33:59 +0900 Subject: [PATCH 3/3] Correct the search endpoint named in the Confluence docs --- docs/confluence.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/confluence.rst b/docs/confluence.rst index 59d58267b..3ba92b8f0 100644 --- a/docs/confluence.rst +++ b/docs/confluence.rst @@ -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