diff --git a/atlassian/bamboo.py b/atlassian/bamboo.py index 7e0948cbf..57370303e 100755 --- a/atlassian/bamboo.py +++ b/atlassian/bamboo.py @@ -47,9 +47,8 @@ def _get_generator( response.raise_for_status() response = response.json() except HTTPError as e: - logging.error(f"Broken response: {e}") - yield e - return + log.error(f"Broken response: {e}") + raise try: results = response[elements_key] size = 0 @@ -62,8 +61,8 @@ def _get_generator( size += 1 yield r except TypeError: - logging.error(f"Broken response: {response}") - yield response + log.error(f"Broken response: {response}") + raise def base_list_call( self, @@ -759,9 +758,9 @@ def delete_build_result(self, build_key): :param build_key: Take full build key, example: PROJECT-PLAN-8 """ custom_resource = "/build/admin/deletePlanResults.action" - build_key = build_key.split("-") - plan_key = f"{build_key[0]}-{build_key[1]}" - build_number = build_key[2] + build_key_parts = build_key.split("-") + plan_key = "-".join(build_key_parts[:-1]) + build_number = build_key_parts[-1] params = {"buildKey": plan_key, "buildNumber": build_number} return self.post(custom_resource, params=params, headers=self.form_token_headers) @@ -906,7 +905,6 @@ def delete_label(self, project_key, plan_key, build_number, label): resource = f"result/{project_key}-{plan_key}-{build_number}/label/{label}" return self.delete(self.resource_url(resource)) - @property def get_projects(self, start=0, limit=25): """Method used to list all projects defined in Bamboo. Projects without any plan are not listed. @@ -2430,7 +2428,6 @@ def upload_plugin(self, plugin_path): :param plugin_path: :return: """ - files = {"plugin": open(plugin_path, "rb")} upm_token = self.request( method="GET", path="rest/plugins/1.0/", @@ -2438,7 +2435,9 @@ def upload_plugin(self, plugin_path): trailing=True, ).headers["upm-token"] url = f"rest/plugins/1.0/?token={upm_token}" - return self.post(url, files=files, headers=self.no_check_headers) + with open(plugin_path, "rb") as plugin_file: + files = {"plugin": plugin_file} + return self.post(url, files=files, headers=self.no_check_headers) def disable_plugin(self, plugin_key): """ diff --git a/atlassian/bitbucket/__init__.py b/atlassian/bitbucket/__init__.py index 916a6003b..56c317911 100644 --- a/atlassian/bitbucket/__init__.py +++ b/atlassian/bitbucket/__init__.py @@ -497,8 +497,10 @@ def project_exists(self, project_key): self.project(project_key) exists = True except HTTPError as e: - if e.response.status_code in (401, 404): + if e.response.status_code == 404: pass + else: + raise return exists def update_project(self, key, **params): @@ -1021,9 +1023,10 @@ def repo_exists(self, project_key, repository_slug): self.get_repo(project_key, repository_slug) exists = True except HTTPError as e: - if e.response.status_code in (401, 404): + if e.response.status_code == 404: pass - return exists + else: + raise def update_repo(self, project_key, repository_slug, **params): """ @@ -3213,12 +3216,7 @@ def get_repo_project_conditions(self, project_key, repo_key): :return: """ response = self.get_repo_conditions(project_key, repo_key) - count = 0 - for condition in response: - if condition["scope"]["type"] == "REPOSITORY": - del response[count] - count += 1 - return response + return [condition for condition in response if condition["scope"]["type"] == "PROJECT"] def get_repo_repo_conditions(self, project_key, repo_key): """ @@ -3232,12 +3230,7 @@ def get_repo_repo_conditions(self, project_key, repo_key): :return: """ response = self.get_repo_conditions(project_key, repo_key) - count = 0 - for condition in response: - if condition["scope"]["type"] == "PROJECT": - del response[count] - count += 1 - return response + return [condition for condition in response if condition["scope"]["type"] == "REPOSITORY"] def _url_repo_condition(self, project_key, repo_key, id_condition=None): return "{}/condition/{}".format( diff --git a/atlassian/bitbucket/cloud/repositories/__init__.py b/atlassian/bitbucket/cloud/repositories/__init__.py index e03d7c5ea..c89bbf830 100644 --- a/atlassian/bitbucket/cloud/repositories/__init__.py +++ b/atlassian/bitbucket/cloud/repositories/__init__.py @@ -204,8 +204,10 @@ def exists(self, repository, by="slug"): self.get(repository, by) exists = True except HTTPError as e: - if e.response.status_code in (401, 404): + if e.response.status_code == 404: pass + else: + raise except Exception as e: if not str(e) == f"Unknown repository {by} '{repository}'": raise e diff --git a/atlassian/bitbucket/cloud/workspaces/__init__.py b/atlassian/bitbucket/cloud/workspaces/__init__.py index 4ac7915ad..4e2bf1d22 100644 --- a/atlassian/bitbucket/cloud/workspaces/__init__.py +++ b/atlassian/bitbucket/cloud/workspaces/__init__.py @@ -91,8 +91,10 @@ def exists(self, workspace): self.get(workspace) exists = True except HTTPError as e: - if e.response.status_code in (401, 404): + if e.response.status_code == 404: pass + else: + raise return exists diff --git a/atlassian/bitbucket/cloud/workspaces/projects.py b/atlassian/bitbucket/cloud/workspaces/projects.py index 5ef26472a..83481525e 100644 --- a/atlassian/bitbucket/cloud/workspaces/projects.py +++ b/atlassian/bitbucket/cloud/workspaces/projects.py @@ -112,8 +112,10 @@ def exists(self, project, by="key"): self.get(project, by) exists = True except HTTPError as e: - if e.response.status_code in (401, 404): + if e.response.status_code == 404: pass + else: + raise except Exception as e: if not str(e) == f"Unknown project {by} '{project}'": raise e diff --git a/atlassian/bitbucket/server/projects/__init__.py b/atlassian/bitbucket/server/projects/__init__.py index 4a997d0a2..2da89a386 100644 --- a/atlassian/bitbucket/server/projects/__init__.py +++ b/atlassian/bitbucket/server/projects/__init__.py @@ -100,8 +100,10 @@ def exists(self, project, by="key"): self.get(project, by) exists = True except HTTPError as e: - if e.response.status_code in (401, 404): + if e.response.status_code == 404: pass + else: + raise except Exception as e: if not str(e) == f"Unknown project {by} '{project}'": raise e diff --git a/atlassian/bitbucket/server/projects/repos/__init__.py b/atlassian/bitbucket/server/projects/repos/__init__.py index f6b078247..14a9bfe2e 100644 --- a/atlassian/bitbucket/server/projects/repos/__init__.py +++ b/atlassian/bitbucket/server/projects/repos/__init__.py @@ -71,8 +71,10 @@ def exists(self, repository, by="slug"): self.get(repository, by) exists = True except HTTPError as e: - if e.response.status_code in (401, 404): + if e.response.status_code == 404: pass + else: + raise except Exception as e: if not str(e) == f"Unknown repository {by} '{repository}'": raise e diff --git a/atlassian/cloud_admin.py b/atlassian/cloud_admin.py index 4f9054866..d7d151fb2 100644 --- a/atlassian/cloud_admin.py +++ b/atlassian/cloud_admin.py @@ -515,7 +515,7 @@ def search_users_in_organization( params["emailUsernames"] = email_usernames if email_domains: params["emailDomains"] = email_domains - if is_suspended: + if is_suspended is not None: params["isSuspended"] = is_suspended if expand: params["expand"] = expand diff --git a/atlassian/confluence/base.py b/atlassian/confluence/base.py index 2554d6e45..d75284863 100644 --- a/atlassian/confluence/base.py +++ b/atlassian/confluence/base.py @@ -195,7 +195,14 @@ def _get_paged( # query. Reusing the already-resolved endpoint preserves both # ``/wiki`` and API-gateway tenant prefixes. parsed_next = urlparse(url) - if getattr(self, "api_version", None) == 2 and parsed_next.query and not parsed_next.scheme: + next_path = parsed_next.path.strip("/") + current_path = urlparse(current_url).path.strip("/") + if ( + str(getattr(self, "api_version", "")) == "2" + and parsed_next.query + and not parsed_next.scheme + and (not next_path or next_path == current_path) + ): url = current_url params = dict(parse_qsl(parsed_next.query, keep_blank_values=True)) trailing = False diff --git a/atlassian/confluence/cloud/__init__.py b/atlassian/confluence/cloud/__init__.py index ff503d92d..59a912cf1 100644 --- a/atlassian/confluence/cloud/__init__.py +++ b/atlassian/confluence/cloud/__init__.py @@ -39,6 +39,17 @@ def __init__(self, url="https://api.atlassian.com/", *args, **kwargs): kwargs["api_root"] = "wiki/api/v2" url = url.strip("/") super(Cloud, self).__init__(url, *args, **kwargs) + # Tenant URLs (e.g. ``https://example.atlassian.net``) serve the REST + # API under the ``/wiki`` context path. API-gateway URLs already + # carry their tenant prefix and must not be rewritten. + from ...confluence_base import ConfluenceBase as VersionedConfluenceBase + + if ( + VersionedConfluenceBase._is_cloud_url(self.url) + and not VersionedConfluenceBase._is_api_gateway_url(self.url) + and "/wiki" not in self.url + ): + self.url = self.url_joiner(self.url, "wiki") def _cloud_wiki_url(self, path): """Return an absolute Cloud URL under the site's ``/wiki`` context.""" @@ -126,7 +137,9 @@ def get_page_as_pdf(self, page_id): download_url = self.get_pdf_download_url_for_confluence_cloud(export_url) if not download_url: raise ApiNotFoundError("Failed to export page as PDF", reason="Failed to get download PDF URL") - response = requests.get(download_url, timeout=75) + response = self.session.get( + download_url, timeout=self.timeout, verify=self.verify_ssl, proxies=self.proxies, cert=self.cert + ) response.raise_for_status() if not response.content.startswith(b"%PDF-"): raise ApiError( @@ -142,7 +155,7 @@ def export_page(self, page_id): # Content Management def get_content(self, content_id, **kwargs): """Get content by ID.""" - return self.get(f"content/{content_id}", **kwargs) + return self.get(f"rest/api/content/{content_id}", **kwargs) def iter_page_versions(self, page_id, limit=200, expand=None): """Yield every version of a legacy Confluence Cloud page lazily.""" @@ -174,55 +187,57 @@ def remove_content_history_in_cloud(self, page_id, version_id): def get_content_by_type(self, content_type, **kwargs): """Get content by type (page, blogpost, etc.).""" - return self.get("content", params={"type": content_type, **kwargs}) + return self.get("rest/api/content", params={"type": content_type, **kwargs}) def get_all_pages_from_space(self, space_key, **kwargs): """Get all pages from space.""" - return self._get_paged("content", params={"spaceKey": space_key, "type": "page", **kwargs}) + return self._get_paged("rest/api/content", params={"spaceKey": space_key, "type": "page", **kwargs}) def get_all_blog_posts_from_space(self, space_key, **kwargs): """Get all blog posts from space.""" - return self._get_paged("content", params={"spaceKey": space_key, "type": "blogpost", **kwargs}) + return self._get_paged("rest/api/content", params={"spaceKey": space_key, "type": "blogpost", **kwargs}) def create_content(self, data, **kwargs): """Create new content.""" - return self.post("content", data=data, **kwargs) + return self.post("rest/api/content", data=data, **kwargs) def update_content(self, content_id, data, **kwargs): """Update existing content.""" - return self.put(f"content/{content_id}", data=data, **kwargs) + return self.put(f"rest/api/content/{content_id}", data=data, **kwargs) def delete_content(self, content_id, **kwargs): """Delete content.""" - return self.delete(f"content/{content_id}", **kwargs) + return self.delete(f"rest/api/content/{content_id}", **kwargs) def get_content_children(self, content_id, **kwargs): """Get child content.""" - return self.get(f"content/{content_id}/children", **kwargs) + return self.get(f"rest/api/content/{content_id}/children", **kwargs) def get_content_descendants(self, content_id, **kwargs): """Get descendant content.""" - return self.get(f"content/{content_id}/descendants", **kwargs) + return self.get(f"rest/api/content/{content_id}/descendants", **kwargs) def get_child_pages(self, content_id, **kwargs): """Get child pages of a content item.""" - return self.get(f"content/{content_id}/child/page", **kwargs) + return self.get(f"rest/api/content/{content_id}/child/page", **kwargs) def get_descendant_pages(self, content_id, **kwargs): """Get all descendant pages of a content item.""" - return self.get(f"content/{content_id}/descendant/page", **kwargs) + return self.get(f"rest/api/content/{content_id}/descendant/page", **kwargs) def get_content_ancestors(self, content_id, **kwargs): """Get ancestor content.""" - return self.get(f"content/{content_id}/ancestors", **kwargs) + return self.get(f"rest/api/content/{content_id}/ancestors", **kwargs) def get_page_by_title(self, space_key, title, **kwargs): """Get page by title and space key.""" - return self.get("content", params={"spaceKey": space_key, "title": title, "type": "page", **kwargs}) + return self.get("rest/api/content", params={"spaceKey": space_key, "title": title, "type": "page", **kwargs}) def get_blog_post_by_title(self, space_key, title, **kwargs): """Get blog post by title and space key.""" - return self.get("content", params={"spaceKey": space_key, "title": title, "type": "blogpost", **kwargs}) + return self.get( + "rest/api/content", params={"spaceKey": space_key, "title": title, "type": "blogpost", **kwargs} + ) def blog_post_exists(self, space_key, title, **kwargs): """Check if blog post exists.""" @@ -237,7 +252,7 @@ def page_exists(self, space_key, title, **kwargs): one matching page. """ try: - spaces = self.get("spaces", params={"keys": [space_key], "limit": 1}) + spaces = self.get("api/v2/spaces", params={"keys": [space_key], "limit": 1}) except HTTPError as error: if error.response is not None and error.response.status_code == 404: return False @@ -248,7 +263,7 @@ def page_exists(self, space_key, title, **kwargs): try: result = self.get( - "pages", + "api/v2/pages", params={ "space-id": space_results[0]["id"], "title": title, @@ -304,6 +319,10 @@ def get_page_child_by_type(self, page_id, type="page", start=None, limit=None, e raise + def get_page_by_id(self, page_id, **kwargs): + """Get a page (or other content) by ID from the Cloud V1 content API.""" + return self.get(f"rest/api/content/{page_id}", **kwargs) + def get_page_child_count(self, page_id, type="page"): """Return the number of direct children of ``type`` without listing them.""" page = self.get_page_by_id(page_id, expand=f"children.{type}") @@ -351,7 +370,7 @@ def get_spaces(self, **kwargs): Calls the Confluence Cloud v2 endpoint ``/wiki/api/v2/spaces``. For paginated enumeration of every space, use :meth:`get_all_spaces`. """ - return self.get("spaces", **kwargs) + return self.get("api/v2/spaces", **kwargs) def get_all_spaces(self, **kwargs): """ @@ -363,7 +382,7 @@ def get_all_spaces(self, **kwargs): not available on the OAuth API gateway and returns ``GoneException: This deprecated endpoint has been removed``. """ - return self._get_paged("spaces", params=kwargs) + return self._get_paged("api/v2/spaces", params=kwargs) def get_space_names(self, **kwargs): """Return the names of every space without fetching page content.""" @@ -371,36 +390,36 @@ def get_space_names(self, **kwargs): def get_space(self, space_id, **kwargs): """Get space by ID.""" - return self.get(f"spaces/{space_id}", **kwargs) + return self.get(f"api/v2/spaces/{space_id}", **kwargs) def create_space(self, data, **kwargs): """Create new space.""" - return self.post("spaces", data=data, **kwargs) + return self.post("api/v2/spaces", data=data, **kwargs) def update_space(self, space_id, data, **kwargs): """Update existing space.""" - return self.put(f"spaces/{space_id}", data=data, **kwargs) + return self.put(f"api/v2/spaces/{space_id}", data=data, **kwargs) def delete_space(self, space_id, **kwargs): """Delete space.""" - return self.delete(f"spaces/{space_id}", **kwargs) + return self.delete(f"api/v2/spaces/{space_id}", **kwargs) def get_space_content(self, space_id, **kwargs): """Get space content.""" - return self.get(f"spaces/{space_id}/content", **kwargs) + return self.get(f"api/v2/spaces/{space_id}/content", **kwargs) # User Management def get_users(self, **kwargs): """Get all users.""" - return self.get("user", **kwargs) + return self.get("rest/api/user", **kwargs) def get_user(self, user_id, **kwargs): """Get user by ID.""" - return self.get(f"user/{user_id}", **kwargs) + return self.get(f"rest/api/user/{user_id}", **kwargs) def get_current_user(self, **kwargs): """Get current user.""" - return self.get("user/current", **kwargs) + return self.get("rest/api/user/current", **kwargs) # Group Management def get_groups(self, start=0, limit=1000, **kwargs): @@ -455,66 +474,66 @@ def get_all_members(self, group_id, expand=None): # Label Management def get_labels(self, **kwargs): """Get all labels.""" - return self.get("label", **kwargs) + return self.get("rest/api/label", **kwargs) def get_content_labels(self, content_id, **kwargs): """Get content labels.""" - return self.get(f"content/{content_id}/label", **kwargs) + return self.get(f"rest/api/content/{content_id}/label", **kwargs) def add_content_labels(self, content_id, data, **kwargs): """Add labels to content.""" - return self.post(f"content/{content_id}/label", data=data, **kwargs) + return self.post(f"rest/api/content/{content_id}/label", data=data, **kwargs) def remove_content_label(self, content_id, label_id, **kwargs): """Remove label from content.""" - return self.delete(f"content/{content_id}/label/{label_id}", **kwargs) + return self.delete(f"rest/api/content/{content_id}/label/{label_id}", **kwargs) # Attachment Management def get_attachments(self, content_id, **kwargs): """Get content attachments.""" - return self.get(f"content/{content_id}/child/attachment", **kwargs) + return self.get(f"rest/api/content/{content_id}/child/attachment", **kwargs) def get_attachment(self, attachment_id, **kwargs): """Get attachment by ID.""" - return self.get(f"content/{attachment_id}", **kwargs) + return self.get(f"rest/api/content/{attachment_id}", **kwargs) def create_attachment(self, content_id, data, **kwargs): """Create new attachment.""" - return self.post(f"content/{content_id}/child/attachment", data=data, **kwargs) + return self.post(f"rest/api/content/{content_id}/child/attachment", data=data, **kwargs) def update_attachment(self, attachment_id, data, **kwargs): """Update existing attachment.""" - return self.put(f"content/{attachment_id}", data=data, **kwargs) + return self.put(f"rest/api/content/{attachment_id}", data=data, **kwargs) def delete_attachment(self, attachment_id, **kwargs): """Delete attachment.""" - return self.delete(f"content/{attachment_id}", **kwargs) + return self.delete(f"rest/api/content/{attachment_id}", **kwargs) # Comment Management def get_comments(self, content_id, **kwargs): """Get content comments.""" - return self.get(f"content/{content_id}/child/comment", **kwargs) + return self.get(f"rest/api/content/{content_id}/child/comment", **kwargs) def get_comment(self, comment_id, **kwargs): """Get comment by ID.""" - return self.get(f"content/{comment_id}", **kwargs) + return self.get(f"rest/api/content/{comment_id}", **kwargs) def create_comment(self, content_id, data, **kwargs): """Create new comment.""" - return self.post(f"content/{content_id}/child/comment", data=data, **kwargs) + return self.post(f"rest/api/content/{content_id}/child/comment", data=data, **kwargs) def update_comment(self, comment_id, data, **kwargs): """Update existing comment.""" - return self.put(f"content/{comment_id}", data=data, **kwargs) + return self.put(f"rest/api/content/{comment_id}", data=data, **kwargs) def delete_comment(self, comment_id, **kwargs): """Delete comment.""" - return self.delete(f"content/{comment_id}", **kwargs) + return self.delete(f"rest/api/content/{comment_id}", **kwargs) # Search def search_content(self, query, **kwargs): """Search content.""" - return self.get("content/search", params={"cql": query, **kwargs}) + return self.get("rest/api/content/search", params={"cql": query, **kwargs}) def cql(self, cql, **kwargs): """Return one page of Cloud CQL search results.""" @@ -522,7 +541,7 @@ def cql(self, cql, **kwargs): def iter_cql(self, cql, **kwargs): """Yield every Cloud CQL result, following pagination links.""" - return self._get_paged("content/search", params={"cql": cql, **kwargs}) + return self._get_paged("rest/api/content/search", params={"cql": cql, **kwargs}) def cql_all(self, cql, **kwargs): """Return all paginated Cloud CQL results as a list. @@ -533,61 +552,61 @@ def cql_all(self, cql, **kwargs): def search_spaces(self, query, **kwargs): """Search spaces.""" - return self.get("space/search", params={"query": query, **kwargs}) + return self.get("rest/api/space/search", params={"query": query, **kwargs}) # Page Properties def get_content_properties(self, content_id, **kwargs): """Get content properties.""" - return self.get(f"content/{content_id}/property", **kwargs) + return self.get(f"rest/api/content/{content_id}/property", **kwargs) def get_content_property(self, content_id, property_key, **kwargs): """Get content property by key.""" - return self.get(f"content/{content_id}/property/{property_key}", **kwargs) + return self.get(f"rest/api/content/{content_id}/property/{property_key}", **kwargs) def create_content_property(self, content_id, data, **kwargs): """Create new content property.""" - return self.post(f"content/{content_id}/property", data=data, **kwargs) + return self.post(f"rest/api/content/{content_id}/property", data=data, **kwargs) def update_content_property(self, content_id, property_key, data, **kwargs): """Update existing content property.""" - return self.put(f"content/{content_id}/property/{property_key}", data=data, **kwargs) + return self.put(f"rest/api/content/{content_id}/property/{property_key}", data=data, **kwargs) def delete_content_property(self, content_id, property_key, **kwargs): """Delete content property.""" - return self.delete(f"content/{content_id}/property/{property_key}", **kwargs) + return self.delete(f"rest/api/content/{content_id}/property/{property_key}", **kwargs) # Templates def get_templates(self, **kwargs): """Get all templates.""" - return self.get("template", **kwargs) + return self.get("rest/api/template", **kwargs) def get_template(self, template_id, **kwargs): """Get template by ID.""" - return self.get(f"template/{template_id}", **kwargs) + return self.get(f"rest/api/template/{template_id}", **kwargs) # Analytics def get_content_analytics(self, content_id, **kwargs): """Get content analytics.""" - return self.get(f"content/{content_id}/analytics", **kwargs) + return self.get(f"rest/api/content/{content_id}/analytics", **kwargs) def get_space_analytics(self, space_id, **kwargs): """Get space analytics.""" - return self.get(f"space/{space_id}/analytics", **kwargs) + return self.get(f"rest/api/space/{space_id}/analytics", **kwargs) # Export def export_content(self, content_id, **kwargs): """Export content.""" - return self.get(f"content/{content_id}/export", **kwargs) + return self.get(f"rest/api/content/{content_id}/export", **kwargs) def export_space(self, space_id, **kwargs): """Export space.""" - return self.get(f"space/{space_id}/export", **kwargs) + return self.get(f"rest/api/space/{space_id}/export", **kwargs) # Utility Methods def get_metadata(self, **kwargs): """Get API metadata.""" - return self.get("metadata", **kwargs) + return self.get("rest/api/metadata", **kwargs) def get_health(self, **kwargs): """Get API health status.""" - return self.get("health", **kwargs) + return self.get("rest/api/health", **kwargs) diff --git a/atlassian/confluence/cloud/cloud.py b/atlassian/confluence/cloud/cloud.py index f07b9a909..b5ab9a9e4 100644 --- a/atlassian/confluence/cloud/cloud.py +++ b/atlassian/confluence/cloud/cloud.py @@ -11,6 +11,8 @@ import warnings from typing import Any, Dict, List, Optional +from requests import HTTPError + from .classification_levels import ClassificationLevelOperations from .content_properties import ContentPropertyOperations from .databases import DatabaseOperations @@ -258,9 +260,10 @@ def get_page_by_title(self, space_key: str, title: str, body_format: str = "stor """ try: space = self.get_space_by_key(space_key) - except Exception as e: - log.error(f"Failed to retrieve space with key '{space_key}': {e}") - return {"results": []} + except HTTPError as e: + if e.response is not None and e.response.status_code == 404: + return {"results": []} + raise if not space or "id" not in space: return {"results": []} diff --git a/atlassian/confluence/server/__init__.py b/atlassian/confluence/server/__init__.py index d70a45e64..05dc27760 100644 --- a/atlassian/confluence/server/__init__.py +++ b/atlassian/confluence/server/__init__.py @@ -249,7 +249,6 @@ def upload_plugin(self, plugin_path: str): :param plugin_path: :return: """ - files = {"plugin": open(plugin_path, "rb")} upm_token = self.request( method="GET", path="rest/plugins/1.0/", @@ -257,7 +256,9 @@ def upload_plugin(self, plugin_path: str): trailing=True, ).headers["upm-token"] url = f"rest/plugins/1.0/?token={upm_token}" - return self.post(url, files=files, headers=self.no_check_headers) + with open(plugin_path, "rb") as plugin_file: + files = {"plugin": plugin_file} + return self.post(url, files=files, headers=self.no_check_headers) def delete_plugin(self, plugin_key: str): """ @@ -476,7 +477,7 @@ def get_parent_content_id(self, page_id): parent_content_id = (self.get_page_by_id(page_id=page_id, expand="ancestors").get("ancestors") or {})[ -1 ].get("id") or None - except Exception as e: + except (KeyError, IndexError, TypeError) as e: log.error(e) return parent_content_id @@ -491,7 +492,7 @@ def get_parent_content_title(self, page_id): parent_content_title = (self.get_page_by_id(page_id=page_id, expand="ancestors").get("ancestors") or {})[ -1 ].get("title") or None - except Exception as e: + except (KeyError, IndexError, TypeError) as e: log.error(e) return parent_content_title @@ -945,7 +946,7 @@ def add_user_in_restricted_page(self, user_name: str, page_id: str, restriction_ self.update_restrictions_for_page_json_rpc( page_id=page_id, permission_type=restriction_type, content_permissions=users_content_edit ) - print( + log.info( f'User "{user_name}" granted restrictions type of "{restriction_type}" on page "{page_name}"' ) elif user_find_edit_bool: @@ -956,45 +957,42 @@ def add_user_in_restricted_page(self, user_name: str, page_id: str, restriction_ raise except Exception as e: log.error(e) + raise - def remove_user_from_restricted_page(self, user_name: str, page_id: str): - page_name = self.get_page_by_id(page_id=page_id).get("title") - user_find_bool = False - users_content_view: list = self.get_users_from_restricts_in_page_by_type( - page_id=page_id, restriction_type="View" - ) - users_content_edit: list = self.get_users_from_restricts_in_page_by_type( - page_id=page_id, restriction_type="Edit" - ) - current_user_content_view = self.create_restricts_from_from_user( - user_name=user_name, restriction_type="View" - ) - current_user_content_edit = self.create_restricts_from_from_user( - user_name=user_name, restriction_type="Edit" - ) - for user_index, user_value in enumerate(users_content_view): - if dict(user_value).get("userName") == current_user_content_view.get("userName"): - user_find_bool = True - users_content_view.pop(user_index) - for user_index, user_value in enumerate(users_content_edit): - if dict(user_value).get("userName") == current_user_content_edit.get("userName"): - user_find_bool = True - users_content_edit.pop(user_index) - try: - if user_find_bool: - self.update_restrictions_for_page_json_rpc( - page_id=page_id, permission_type="View", content_permissions=users_content_view - ) - self.update_restrictions_for_page_json_rpc( - page_id=page_id, permission_type="Edit", content_permissions=users_content_edit - ) - print(f'User "{user_name}" has been deleted from restrictions on page "{page_name}"') - elif not user_find_bool: - raise JsonRPCRestrictionsError( - f'User "{user_name}" has not founded in restrictions on page "{page_name}"' - ) - except JsonRPCError: - raise + def remove_user_from_restricted_page(self, user_name: str, page_id: str): + page_name = self.get_page_by_id(page_id=page_id).get("title") + user_find_bool = False + users_content_view: list = self.get_users_from_restricts_in_page_by_type( + page_id=page_id, restriction_type="View" + ) + users_content_edit: list = self.get_users_from_restricts_in_page_by_type( + page_id=page_id, restriction_type="Edit" + ) + current_user_content_view = self.create_restricts_from_from_user(user_name=user_name, restriction_type="View") + current_user_content_edit = self.create_restricts_from_from_user(user_name=user_name, restriction_type="Edit") + for user_index, user_value in enumerate(users_content_view): + if dict(user_value).get("userName") == current_user_content_view.get("userName"): + user_find_bool = True + users_content_view.pop(user_index) + for user_index, user_value in enumerate(users_content_edit): + if dict(user_value).get("userName") == current_user_content_edit.get("userName"): + user_find_bool = True + users_content_edit.pop(user_index) + try: + if user_find_bool: + self.update_restrictions_for_page_json_rpc( + page_id=page_id, permission_type="View", content_permissions=users_content_view + ) + self.update_restrictions_for_page_json_rpc( + page_id=page_id, permission_type="Edit", content_permissions=users_content_edit + ) + log.info(f'User "{user_name}" has been deleted from restrictions on page "{page_name}"') + elif not user_find_bool: + raise JsonRPCRestrictionsError( + f'User "{user_name}" has not founded in restrictions on page "{page_name}"' + ) + except JsonRPCError: + raise def move_page( self, @@ -1432,8 +1430,11 @@ def attach_content( if attachment.get("title") == name: existing_attachment = attachment break - except HTTPError: - pass + except HTTPError as e: + if e.response is not None and e.response.status_code == 404: + pass + else: + raise if existing_attachment: # Update existing attachment on the specific attachment ID @@ -1652,7 +1653,7 @@ def download_attachments_from_page( request=http_err.request, ) except Exception as err: - raise Exception(f"An unexpected error occurred: {err}") + raise Exception(f"An unexpected error occurred: {err}") from err def delete_attachment_by_id(self, attachment_id, version): """ @@ -1705,9 +1706,12 @@ def remove_page_attachment_keep_version(self, page_id, filename, keep_last_versi :param keep_last_versions: :return: """ - attachment = self.get_attachments_from_content(page_id=page_id, expand="version", filename=filename).get( + results = self.get_attachments_from_content(page_id=page_id, expand="version", filename=filename).get( "results" - )[0] + ) + if not results: + raise ApiNotFoundError(f"No attachment named {filename} found on page {page_id}") + attachment = results[0] attachment_versions = self.get_attachment_history(attachment.get("id")) while len(attachment_versions) > keep_last_versions: remove_version_attachment_number = attachment_versions[keep_last_versions].get("number") @@ -2016,7 +2020,7 @@ def is_page_content_is_already_updated(self, page_id, body, title=None): log.debug('Old Content: """%s"""', confluence_body_content) log.debug('New Content: """%s"""', body) - if confluence_body_content.strip().lower() == body.strip().lower(): + if confluence_body_content is not None and confluence_body_content.strip().lower() == body.strip().lower(): log.info("Content of %s is exactly the same", page_id) return True else: @@ -3829,7 +3833,7 @@ def get_atl_request(link: str): progress_url = urljoin(ui_base_url + "/", poll_url) progress_response = self.get(progress_url, absolute=True) or {} log.info(f"Space {space_key} export status: {progress_response.get('message', 'None')}") - if progress_response is not {} and progress_response.get("complete"): + if progress_response and progress_response.get("complete"): parsed_html = BeautifulSoup(progress_response.get("message"), "html.parser") download_url = cast( "str", parsed_html.find("a", {"class": "space-export-download-path"}).get("href") @@ -3841,7 +3845,7 @@ def get_atl_request(link: str): "Encountered error during space export status check from space " + space_key, reason=e ) - return "None" # Return None if the while loop does not return a value + raise ApiError(f"Space export for {space_key} did not complete") except Exception as e: raise ApiError("Encountered error during space export from space " + space_key, reason=e) @@ -3879,10 +3883,11 @@ def get_descendant_page_id(self, space, parent_id, title): """ page_id = "" - url = f'rest/api/content/search?cql=parent={parent_id}%20AND%20space="{space}"' + url = "rest/api/content/search" + cql = f'parent={parent_id} AND space="{space}"' try: - response = self.get(url, {}) + response = self.get(url, params={"cql": cql}) except HTTPError as e: if e.response.status_code == 400: raise ApiValueError("The CQL is invalid or missing", reason=e) diff --git a/atlassian/crowd.py b/atlassian/crowd.py index 23912ef82..18358daab 100644 --- a/atlassian/crowd.py +++ b/atlassian/crowd.py @@ -1,7 +1,7 @@ # coding=utf-8 import logging +from xml.etree import ElementTree -from bs4 import BeautifulSoup from jmespath import search from .rest_client import AtlassianRestAPI @@ -166,7 +166,11 @@ def is_user_in_group(self, username, group, kind="direct"): path = self._crowd_api_url("usermanagement", f"group/user/{kind}") params = {"username": username, "groupname": group} response = self.get(path, params=params, advanced_mode=True) - return response.status_code == 200 + if response.status_code == 200: + return True + if response.status_code == 404: + return False + response.raise_for_status() def group_add_user(self, username, groupname): """ @@ -497,12 +501,14 @@ def user_rename(self, username, new_name): data = {"newName": new_name} return self.post(self._crowd_api_url("usermanagement", "user/rename"), params=params, data=data) - def user_expire_all_passwords(self, confirm=True): + def user_expire_all_passwords(self, confirm=False): """ Expire all user passwords. - :param confirm: bool - must be True to perform the action + :param confirm: bool - must be explicitly True to perform this destructive action :return: """ + if confirm is not True: + raise ValueError("expire-all-passwords requires confirm=True") params = {"confirm": str(confirm).lower()} return self.post(self._crowd_api_url("usermanagement", "user/expire-all-passwords"), params=params) @@ -516,7 +522,7 @@ def user_avatar(self, username, size=None): params = {"username": username} if size: params["s"] = size - return self.get(self._crowd_api_url("usermanagement", "user/avatar"), params=params) + return self.get(self._crowd_api_url("usermanagement", "user/avatar"), params=params, not_json_response=True) def get_cookie_config(self): """Get the Crowd cookie configuration.""" @@ -1322,9 +1328,10 @@ def search_audit_log(self, query=None, start=0, limit=99999): params = {"start": start, "limit": limit} return self.post(self._admin_api_url("auditlog/query"), params=params, data=query or {}) - def get_audit_log_filter_values(self, projection=None, search=None, start=0, limit=99999): + def get_audit_log_filter_values(self, query=None, projection=None, search=None, start=0, limit=99999): """ Get audit log filter values. + :param query: dict - filter request body :param projection: str - item type requested :param search: str - optional search string :param start: int - start index for paging @@ -1336,7 +1343,7 @@ def get_audit_log_filter_values(self, projection=None, search=None, start=0, lim params["projection"] = projection if search: params["search"] = search - return self.post(self._admin_api_url("auditlog/query/filter"), params=params) + return self.post(self._admin_api_url("auditlog/query/filter"), params=params, data=query or {}) def get_look_and_feel_config(self): """Get look and feel configuration.""" @@ -1499,7 +1506,6 @@ def upload_plugin(self, plugin_path): :param plugin_path: :return: """ - files = {"plugin": open(plugin_path, "rb")} upm_token = self.request( method="GET", path="rest/plugins/1.0/", @@ -1507,7 +1513,9 @@ def upload_plugin(self, plugin_path): trailing=True, ).headers["upm-token"] url = f"rest/plugins/1.0/?token={upm_token}" - return self.post(url, files=files, headers=self.no_check_headers) + with open(plugin_path, "rb") as plugin_file: + files = {"plugin": plugin_file} + return self.post(url, files=files, headers=self.no_check_headers) def delete_plugin(self, plugin_key): """ @@ -1533,7 +1541,7 @@ def update_plugin_license(self, plugin_key, raw_license): "X-Atlassian-Token": "no-check", "Content-Type": "application/vnd.atl.plugins+json", } - url = f"/plugins/1.0/{plugin_key}/license" + url = f"rest/plugins/1.0/{plugin_key}/license" data = {"rawLicense": raw_license} return self.put(url, data=data, headers=app_headers) @@ -1547,11 +1555,11 @@ def memberships(self): path = self._crowd_api_url("usermanagement", "group/membership") headers = {"Accept": "application/xml"} response = self.get(path, headers=headers) - soup = BeautifulSoup(response, "xml") + memberships_root = ElementTree.fromstring(response.encode("utf-8") if isinstance(response, str) else response) memberships = {} - for membership in soup.find_all("membership"): - group = membership["group"] - users = [user["name"] for user in membership.find_all("user")] + for membership in memberships_root.iter("membership"): + group = membership.attrib["group"] + users = [user.attrib["name"] for user in membership.iter("user")] memberships[group] = users return memberships diff --git a/atlassian/insight.py b/atlassian/insight.py index ef779a475..b563d77df 100644 --- a/atlassian/insight.py +++ b/atlassian/insight.py @@ -374,7 +374,7 @@ def get_object_history(self, object_id, asc=False, abbreviate=True): @deprecated(version="3.29.0", reason="Use get_object_reference_info()") def get_object_referenceinfo(self, object_id): """Let's use the get_object_reference_info()""" - self.get_object_reference_info(object_id) + return self.get_object_reference_info(object_id) def get_object_reference_info(self, object_id): """ @@ -498,9 +498,9 @@ def list_object_schema(self): def create_object_schema(self, object_schema_key, description): raise NotImplementedError - @deprecated(version="3.29.1", reason="Use get_objectschema()") + @deprecated(version="3.29.1", reason="Use get_object_schema()") def get_objectschema(self, schema_id): - return self.get_objectschema(schema_id=schema_id) + return self.get_object_schema(schema_id=schema_id) def get_object_schema(self, schema_id): """ diff --git a/atlassian/jira/jira_server.py b/atlassian/jira/jira_server.py index b235da7f6..d9dcda246 100644 --- a/atlassian/jira/jira_server.py +++ b/atlassian/jira/jira_server.py @@ -1565,7 +1565,7 @@ def bulk_issue( if query_result and "errorMessages" in list(query_result.keys()): for message in query_result["errorMessages"]: for key in issue_list: - if key in message: + if re.search(rf"\b{re.escape(key)}\b", message): missing_issues.append(key) remaining_issues = [key for key in issue_list if key not in missing_issues] if remaining_issues != issue_list: @@ -1843,16 +1843,17 @@ def bulk_update_issue_field(self, key_list: list, fields: Union[str, dict] = "*a return Boolean True/False """ base_url = self.resource_url("issue") - try: - for key in key_list: + failed_keys = [] + for key in key_list: + try: self.put( f"{base_url}/{key}", data={"fields": fields}, ) - except Exception as e: - log.error(e) - return False - return True + except Exception as e: + log.error("Failed to update issue %s: %s", key, e) + failed_keys.append(key) + return failed_keys def issue_field_value_append(self, issue_id_or_key: str, field: str, value: str, notify_users: bool = True): """ @@ -2028,7 +2029,7 @@ def issue_update( :param notify_users: Whether to notify users of the update. default: True :return: Response from the PUT request. """ - log.info(f'Updating issue "{issue_key}" with "{fields}", "{update}", "{history_metadata}", and "{properties}"') + log.info("Updating issue %s", issue_key) base_url = self.resource_url("issue") url = f"{base_url}/{issue_key}" @@ -2755,9 +2756,9 @@ def user( elif not major_parameter_enabled and not username and key: params = {"key": key} elif not major_parameter_enabled and username and key: - return "You cannot specify both the username and the key parameters" + raise ValueError("You cannot specify both the username and the key parameters") elif not account_id and not key and not username: - return "You must specify at least one parameter: username or key or account_id" + raise ValueError("You must specify at least one parameter: username or key or account_id") if expand: params["expand"] = expand @@ -2858,9 +2859,7 @@ def user_create( else: data["notification"] = True if notification is not None: - data["notification"] = True - if notification is False: - data["notification"] = False + data["notification"] = bool(notification) url = self.resource_url("user") return self.post(url, data=data) @@ -2872,12 +2871,14 @@ def user_properties(self, username: Optional[str] = None, account_id: Optional[s :return: """ base_url = self.resource_url("user/properties") - url = "" + params: dict = {} if username or not self.cloud: - url = f"{base_url}?accountId={username}" + params = {"username": username} elif account_id or self.cloud: - url = f"{base_url}?accountId={account_id}" - return self.get(url) + params = {"accountId": account_id} + else: + raise ValueError("Either username or account_id must be provided") + return self.get(base_url, params=params) def user_property( self, username: Optional[str] = None, account_id: Optional[str] = None, key_property: Optional[str] = None @@ -3818,10 +3819,15 @@ def get_project_issues_count(self, project: str): Decoded Jira REST response. """ jql = f'project = "{project}" ' - response = self.jql(jql, fields="*none") + if self.cloud: + response = self.approximate_issue_count(jql) + if self.advanced_mode: + return cast("Response", response) + return cast("dict", response).get("count", cast("dict", response)) + response = self.jql(jql, fields="*none", limit=1) if self.advanced_mode: return cast("Response", response) - return len(cast("dict", response)["issues"]) + return cast("dict", response)["total"] def get_all_project_issues( self, project: str, fields: Union[str, List[str]] = "*all", start: int = 0, limit: Optional[int] = None @@ -4776,7 +4782,7 @@ def update_plugin_license(self, plugin_key: str, raw_license: str) -> T_resp_jso "X-Atlassian-Token": "no-check", "Content-Type": "application/vnd.atl.plugins+json", } - url = f"/plugins/1.0/{plugin_key}/license" + url = f"rest/plugins/1.0/{plugin_key}/license" data = {"rawLicense": raw_license} return self.put(url, data=data, headers=app_headers) diff --git a/atlassian/portfolio.py b/atlassian/portfolio.py index c12f1f129..64c708f1a 100644 --- a/atlassian/portfolio.py +++ b/atlassian/portfolio.py @@ -38,7 +38,10 @@ def get_teams(self): def get_team_name(self, team_id): all_teams = self.get_teams()["collection"] - return [team["title"] for team in all_teams if team["id"] == str(team_id)][0] + titles = [team["title"] for team in all_teams if team["id"] == str(team_id)] + if not titles: + raise ValueError(f"No team found with id {team_id}") + return titles[0] def get_config(self): url = f"rest/roadmap/1.0/plans/{self.plan_id}/config.json" @@ -68,8 +71,8 @@ def get_filter(self, limit=500): return self.post(url, data={"limit": limit}) def get_filters(self, query_string): - url = f"rest/roadmap/1.0/system/filters.json?queryString={query_string}" - return self.get(url) + url = "rest/roadmap/1.0/system/filters.json" + return self.get(url, params={"queryString": query_string}) def get_dependencies(self, workitem_id, plan_version): url = f"rest/roadmap/1.0/workitems/{workitem_id}/dependencies.json?planVersion={plan_version}" @@ -77,7 +80,10 @@ def get_dependencies(self, workitem_id, plan_version): def get_stage_name(self, stage_id): all_stages = self.get_stages()["collection"] - return [stage["title"] for stage in all_stages if stage["id"] == str(stage_id)][0] + titles = [stage["title"] for stage in all_stages if stage["id"] == str(stage_id)] + if not titles: + raise ValueError(f"No stage found with id {stage_id}") + return titles[0] def get_estimates_dict(self, estimates): return {self.get_stage_name(stage["targetId"]): stage["value"] for stage in estimates["stages"]} @@ -105,4 +111,8 @@ def get_jql_issues( "estimationMethod": estimation_method, "loadStoryPoints": load_story_points, } - return self.post(url, data=data)["data"]["items"] + response = self.post(url, data=data) + try: + return response["data"]["items"] + except (KeyError, TypeError) as error: + raise ValueError(f"Unexpected response from Portfolio import endpoint: {response!r}") from error diff --git a/atlassian/rest_client.py b/atlassian/rest_client.py index aa58c4d64..ad19e7a13 100644 --- a/atlassian/rest_client.py +++ b/atlassian/rest_client.py @@ -125,7 +125,7 @@ def __init__( token: Optional[str] = None, cert: Union[str, Tuple[str, str], None] = None, backoff_and_retry: bool = False, - retry_status_codes: List[int] = [413, 429, 503], + retry_status_codes: Optional[List[int]] = None, max_backoff_seconds: int = 1800, max_backoff_retries: int = 1000, backoff_factor=1.0, @@ -199,7 +199,7 @@ def __init__( self.cert = cert self.backoff_and_retry = backoff_and_retry self.max_backoff_retries = max_backoff_retries - self.retry_status_codes = retry_status_codes + self.retry_status_codes = retry_status_codes if retry_status_codes is not None else [413, 429, 503] self.max_backoff_seconds = max_backoff_seconds self.use_urllib3_retry = int(urllib3.__version__.split(".")[0]) >= 2 self.backoff_factor = backoff_factor @@ -529,7 +529,8 @@ def request( url += ("&" if params or params_already_in_url else "") + "&".join(flags or []) json_dump = None if files is None: - data = None if data is None else dumps(data) + # Only JSON-encode structured payloads; a plain string body is sent as-is. + data = None if data is None else (data if isinstance(data, str) else dumps(data)) json_dump = None if json is None else dumps(json) headers = headers or self.default_headers diff --git a/atlassian/service_desk.py b/atlassian/service_desk.py index 7016cb882..3deb398a7 100644 --- a/atlassian/service_desk.py +++ b/atlassian/service_desk.py @@ -484,7 +484,7 @@ def delete_organization(self, organization_id): return self.delete(url, headers=self.experimental_headers) - def add_users_to_organization(self, organization_id, users_list=[], account_list=[]): + def add_users_to_organization(self, organization_id, users_list=None, account_list=None): """ Adds users to an organization users_list is a list of strings @@ -497,11 +497,11 @@ def add_users_to_organization(self, organization_id, users_list=[], account_list """ log.info("Adding users: %s ", str(users_list)) url = f"rest/servicedeskapi/organization/{organization_id}/user" - data = {"usernames": users_list, "accountIds": account_list} + data = {"usernames": users_list or [], "accountIds": account_list or []} return self.post(url, headers=self.experimental_headers, data=data) - def remove_users_from_organization(self, organization_id, users_list=[], account_list=[]): + def remove_users_from_organization(self, organization_id, users_list=None, account_list=None): """ Removes users from an organization users_list is a list of strings @@ -514,7 +514,7 @@ def remove_users_from_organization(self, organization_id, users_list=[], account """ log.info("Removing users: %s", str(users_list)) url = f"rest/servicedeskapi/organization/{organization_id}/user" - data = {"usernames": users_list, "accountIds": account_list} + data = {"usernames": users_list or [], "accountIds": account_list or []} return self.delete(url, headers=self.experimental_headers, data=data) @@ -613,6 +613,8 @@ def attach_temporary_file(self, service_desk_id, filename): result = self.post(path=url, headers=experimental_headers, files={"file": file}).get( "temporaryAttachments" ) + if not result: + raise HTTPError(f"No temporary attachment was created for {filename}") temp_attachment_id = result[0].get("temporaryAttachmentId") return temp_attachment_id @@ -629,8 +631,9 @@ def add_attachments(self, issue_id_or_key, temp_attachment_ids, public=True, com data = { "temporaryAttachmentIds": temp_attachment_ids, "public": public, - "additionalComment": {"body": comment}, } + if comment is not None: + data["additionalComment"] = {"body": comment} url = f"rest/servicedeskapi/request/{issue_id_or_key}/attachment" return self.post(url, headers=self.experimental_headers, data=data) @@ -824,7 +827,7 @@ def get_customers(self, service_desk_id, query=None, start=0, limit=50): return self.get(url, headers=self.experimental_headers, params=params) - def add_customers(self, service_desk_id, list_of_usernames=[], list_of_accountids=[]): + def add_customers(self, service_desk_id, list_of_usernames=None, list_of_accountids=None): """ Adds one or more existing customers to the given service desk. If you need to create a customer, see Create customer method. @@ -839,14 +842,14 @@ def add_customers(self, service_desk_id, list_of_usernames=[], list_of_accountid """ url = f"rest/servicedeskapi/servicedesk/{service_desk_id}/customer" data = { - "usernames": list_of_usernames, - "accountIds": list_of_accountids, + "usernames": list_of_usernames or [], + "accountIds": list_of_accountids or [], } log.info("Adding customers...") return self.post(url, headers=self.experimental_headers, data=data) - def remove_customers(self, service_desk_id, list_of_usernames=[], list_of_accountids=[]): + def remove_customers(self, service_desk_id, list_of_usernames=None, list_of_accountids=None): """ Removes one or more customers from a service desk. The service desk must have closed access. If any of the passed customers are @@ -860,8 +863,8 @@ def remove_customers(self, service_desk_id, list_of_usernames=[], list_of_accoun """ url = f"rest/servicedeskapi/servicedesk/{service_desk_id}/customer" data = { - "usernames": list_of_usernames, - "accountIds": list_of_accountids, + "usernames": list_of_usernames or [], + "accountIds": list_of_accountids or [], } log.info("Removing customers...") @@ -1090,7 +1093,6 @@ def upload_plugin(self, plugin_path): :param plugin_path: :return: """ - files = {"plugin": open(plugin_path, "rb")} upm_token = self.request( method="GET", path="rest/plugins/1.0/", @@ -1098,7 +1100,9 @@ def upload_plugin(self, plugin_path): trailing=True, ).headers["upm-token"] url = f"rest/plugins/1.0/?token={upm_token}" - return self.post(url, files=files, headers=self.no_check_headers) + with open(plugin_path, "rb") as plugin_file: + files = {"plugin": plugin_file} + return self.post(url, files=files, headers=self.no_check_headers) def delete_plugin(self, plugin_key): """ @@ -1347,6 +1351,7 @@ def raise_for_status(self, response): raise HTTPError("Unauthorized (401)", response=response) if 400 <= response.status_code < 600: + error_msg = None try: j = response.json() if "errorMessage" in j: @@ -1357,4 +1362,6 @@ def raise_for_status(self, response): log.error(e) response.raise_for_status() else: + if error_msg is None: + response.raise_for_status() raise HTTPError(error_msg, response=response) diff --git a/atlassian/utils.py b/atlassian/utils.py index 24a479993..f9e310774 100644 --- a/atlassian/utils.py +++ b/atlassian/utils.py @@ -318,6 +318,8 @@ def parse_cookie_file(cookie_file): cookies = {} with open(cookie_file, "r") as fp: for line in fp: + if line.startswith("#HttpOnly_"): + line = line[len("#HttpOnly_") :] if not re.match(r"^(#|$)", line): line_fields = line.strip().split("\t") try: diff --git a/tests/confluence/test_confluence_cloud.py b/tests/confluence/test_confluence_cloud.py index da0b1c89c..3615cdea9 100644 --- a/tests/confluence/test_confluence_cloud.py +++ b/tests/confluence/test_confluence_cloud.py @@ -8,6 +8,7 @@ from unittest.mock import patch from atlassian.confluence import ConfluenceCloud +from atlassian.confluence.cloud import Cloud from atlassian.confluence.cloud.cloud import ConfluenceCloud as ConfluenceCloudV2 from atlassian.errors import ApiError @@ -62,14 +63,15 @@ def test_iter_cql_follows_all_result_pages(self, mock_get_paged, confluence_clou mock_get_paged.return_value = iter([{"id": "1"}, {"id": "2"}]) assert list(confluence_cloud.iter_cql("type=page", limit=250)) == [{"id": "1"}, {"id": "2"}] - mock_get_paged.assert_called_once_with("content/search", params={"cql": "type=page", "limit": 250}) + mock_get_paged.assert_called_once_with("rest/api/content/search", params={"cql": "type=page", "limit": 250}) @patch.object(ConfluenceCloud, "get") def test_cql_passes_the_nested_content_storage_expansion(self, mock_get, confluence_cloud): confluence_cloud.cql("type=page", limit=25, expand="content.body.storage") mock_get.assert_called_once_with( - "content/search", params={"cql": "type=page", "limit": 25, "expand": "content.body.storage"} + "rest/api/content/search", + params={"cql": "type=page", "limit": 25, "expand": "content.body.storage"}, ) @patch.object(ConfluenceCloud, "iter_cql") @@ -118,14 +120,14 @@ def test_get_content_template_uses_cloud_v1_endpoint(self, mock_get, confluence_ mock_get.assert_called_once_with("https://test.atlassian.net/wiki/rest/api/template/template-1", absolute=True) - @patch("atlassian.confluence.cloud.requests.get") + @patch.object(Cloud, "session") @patch.object(ConfluenceCloud, "get") - def test_export_page_uses_v2_pdf_export_task_endpoint(self, mock_get, mock_requests_get, confluence_cloud): + def test_export_page_uses_v2_pdf_export_task_endpoint(self, mock_get, mock_session, confluence_cloud): mock_get.side_effect = [ b'', {"state": "SUCCEEDED", "progress": 100, "result": "https://downloads.example.test/page.pdf"}, ] - mock_requests_get.return_value.content = b"%PDF-1.7" + mock_session.get.return_value.content = b"%PDF-1.7" result = confluence_cloud.export_page("456") @@ -135,13 +137,15 @@ def test_export_page_uses_v2_pdf_export_task_endpoint(self, mock_get, mock_reque == "https://test.atlassian.net/wiki/api/v2/pdfexporttask/progress/task-123" ) assert mock_get.call_args_list[1].kwargs["absolute"] is True - mock_requests_get.assert_called_once_with("https://downloads.example.test/page.pdf", timeout=75) + mock_session.get.assert_called_once() + assert mock_session.get.call_args.args[0] == "https://downloads.example.test/page.pdf" - @patch("atlassian.confluence.cloud.requests.get") + @patch.object(Cloud, "session") @patch.object(ConfluenceCloud, "get_pdf_download_url_for_confluence_cloud") - def test_export_page_rejects_html_response(self, mock_download_url, mock_requests_get, confluence_cloud): + def test_export_page_rejects_html_response(self, mock_download_url, mock_session, confluence_cloud): mock_download_url.return_value = "https://downloads.example.test/page.pdf" - mock_requests_get.return_value.content = b"Sign in" + mock_session.get.return_value.content = b"Sign in" + mock_session.get.return_value.raise_for_status = lambda: None with pytest.raises(ApiError, match="non-PDF content"): confluence_cloud.export_page("456") @@ -152,7 +156,7 @@ def test_get_content(self, mock_get, confluence_cloud): """Test get_content method.""" mock_get.return_value = {"id": "123", "title": "Test Page", "type": "page"} result = confluence_cloud.get_content("123") - mock_get.assert_called_once_with("content/123", **{}) + mock_get.assert_called_once_with("rest/api/content/123", **{}) assert result == {"id": "123", "title": "Test Page", "type": "page"} @patch.object(ConfluenceCloud, "get") @@ -160,7 +164,7 @@ def test_get_content_by_type(self, mock_get, confluence_cloud): """Test get_content_by_type method.""" mock_get.return_value = {"results": [{"id": "123", "title": "Test Page"}]} result = confluence_cloud.get_content_by_type("page") - mock_get.assert_called_once_with("content", params={"type": "page", **{}}) + mock_get.assert_called_once_with("rest/api/content", params={"type": "page", **{}}) assert result == {"results": [{"id": "123", "title": "Test Page"}]} @patch.object(ConfluenceCloud, "get") @@ -170,7 +174,7 @@ def test_get_all_pages_from_space(self, mock_get, confluence_cloud): result = confluence_cloud.get_all_pages_from_space("TEST") assert list(result) == [{"id": "123", "title": "Page in Space"}] mock_get.assert_called_once_with( - "content", + "rest/api/content", params={"spaceKey": "TEST", "type": "page", **{}}, trailing=None, data=None, @@ -199,9 +203,9 @@ def test_page_exists_uses_v2_space_and_page_endpoints(self, mock_get, confluence mock_get.side_effect = [{"results": [{"id": "42", "key": "TEST"}]}, {"results": [{"id": "123"}]}] assert confluence_cloud.page_exists("TEST", "Test Page") is True - assert mock_get.call_args_list[0].args == ("spaces",) + assert mock_get.call_args_list[0].args == ("api/v2/spaces",) assert mock_get.call_args_list[0].kwargs == {"params": {"keys": ["TEST"], "limit": 1}} - assert mock_get.call_args_list[1].args == ("pages",) + assert mock_get.call_args_list[1].args == ("api/v2/pages",) assert mock_get.call_args_list[1].kwargs == { "params": { "space-id": "42", @@ -217,7 +221,7 @@ def test_page_exists_returns_false_for_unknown_space(self, mock_get, confluence_ mock_get.return_value = {"results": []} assert confluence_cloud.page_exists("MISSING", "Test Page") is False - mock_get.assert_called_once_with("spaces", params={"keys": ["MISSING"], "limit": 1}) + mock_get.assert_called_once_with("api/v2/spaces", params={"keys": ["MISSING"], "limit": 1}) @patch.object(ConfluenceCloud, "get") def test_get_all_blog_posts_from_space(self, mock_get, confluence_cloud): @@ -226,7 +230,7 @@ def test_get_all_blog_posts_from_space(self, mock_get, confluence_cloud): result = confluence_cloud.get_all_blog_posts_from_space("TEST") assert list(result) == [{"id": "456", "title": "Blog Post"}] mock_get.assert_called_once_with( - "content", + "rest/api/content", params={"spaceKey": "TEST", "type": "blogpost", **{}}, trailing=None, data=None, @@ -240,7 +244,7 @@ def test_create_content(self, mock_post, confluence_cloud): content_data = {"title": "New Page", "type": "page", "spaceId": "TEST"} mock_post.return_value = {"id": "456", "title": "New Page", "type": "page"} result = confluence_cloud.create_content(content_data) - mock_post.assert_called_once_with("content", data=content_data, **{}) + mock_post.assert_called_once_with("rest/api/content", data=content_data, **{}) assert result == {"id": "456", "title": "New Page", "type": "page"} @patch.object(ConfluenceCloud, "put") @@ -249,7 +253,7 @@ def test_update_content(self, mock_put, confluence_cloud): content_data = {"title": "Updated Page"} mock_put.return_value = {"id": "123", "title": "Updated Page"} result = confluence_cloud.update_content("123", content_data) - mock_put.assert_called_once_with("content/123", data=content_data, **{}) + mock_put.assert_called_once_with("rest/api/content/123", data=content_data, **{}) assert result == {"id": "123", "title": "Updated Page"} @patch.object(ConfluenceCloud, "delete") @@ -257,7 +261,7 @@ def test_delete_content(self, mock_delete, confluence_cloud): """Test delete_content method.""" mock_delete.return_value = {"success": True} result = confluence_cloud.delete_content("123") - mock_delete.assert_called_once_with("content/123", **{}) + mock_delete.assert_called_once_with("rest/api/content/123", **{}) assert result == {"success": True} @patch.object(ConfluenceCloud, "get") @@ -265,7 +269,7 @@ def test_get_content_children(self, mock_get, confluence_cloud): """Test get_content_children method.""" mock_get.return_value = {"results": [{"id": "789", "title": "Child Page"}]} result = confluence_cloud.get_content_children("123") - mock_get.assert_called_once_with("content/123/children", **{}) + mock_get.assert_called_once_with("rest/api/content/123/children", **{}) assert result == {"results": [{"id": "789", "title": "Child Page"}]} @patch.object(ConfluenceCloud, "get") @@ -273,7 +277,7 @@ def test_get_content_descendants(self, mock_get, confluence_cloud): """Test get_content_descendants method.""" mock_get.return_value = {"results": [{"id": "999", "title": "Descendant Page"}]} result = confluence_cloud.get_content_descendants("123") - mock_get.assert_called_once_with("content/123/descendants", **{}) + mock_get.assert_called_once_with("rest/api/content/123/descendants", **{}) assert result == {"results": [{"id": "999", "title": "Descendant Page"}]} @patch.object(ConfluenceCloud, "get") @@ -281,7 +285,7 @@ def test_get_content_ancestors(self, mock_get, confluence_cloud): """Test get_content_ancestors method.""" mock_get.return_value = {"results": [{"id": "111", "title": "Ancestor Page"}]} result = confluence_cloud.get_content_ancestors("123") - mock_get.assert_called_once_with("content/123/ancestors", **{}) + mock_get.assert_called_once_with("rest/api/content/123/ancestors", **{}) assert result == {"results": [{"id": "111", "title": "Ancestor Page"}]} # Space Management Tests @@ -290,7 +294,7 @@ def test_get_spaces(self, mock_get, confluence_cloud): """get_spaces calls the v2 plural endpoint /wiki/api/v2/spaces.""" mock_get.return_value = {"results": [{"id": "TEST", "name": "Test Space"}]} result = confluence_cloud.get_spaces() - mock_get.assert_called_once_with("spaces", **{}) + mock_get.assert_called_once_with("api/v2/spaces", **{}) assert result == {"results": [{"id": "TEST", "name": "Test Space"}]} @patch.object(ConfluenceCloud, "get") @@ -311,7 +315,7 @@ def test_get_all_spaces_paginates(self, mock_get, confluence_cloud): ] # Entry-point URL is the v2 plural path; pagination URL handling is # covered by existing _get_paged tests. - assert mock_get.call_args_list[0].args[0] == "spaces" + assert mock_get.call_args_list[0].args[0] == "api/v2/spaces" @patch.object(ConfluenceCloud, "get_all_spaces") def test_get_space_names(self, mock_get_all_spaces, confluence_cloud): @@ -324,7 +328,7 @@ def test_get_space(self, mock_get, confluence_cloud): """get_space calls the v2 plural endpoint.""" mock_get.return_value = {"id": "TEST", "name": "Test Space"} result = confluence_cloud.get_space("TEST") - mock_get.assert_called_once_with("spaces/TEST", **{}) + mock_get.assert_called_once_with("api/v2/spaces/TEST", **{}) assert result == {"id": "TEST", "name": "Test Space"} @patch.object(ConfluenceCloud, "post") @@ -333,7 +337,7 @@ def test_create_space(self, mock_post, confluence_cloud): space_data = {"name": "New Space", "key": "NEW"} mock_post.return_value = {"id": "NEW", "name": "New Space", "key": "NEW"} result = confluence_cloud.create_space(space_data) - mock_post.assert_called_once_with("spaces", data=space_data, **{}) + mock_post.assert_called_once_with("api/v2/spaces", data=space_data, **{}) assert result == {"id": "NEW", "name": "New Space", "key": "NEW"} @patch.object(ConfluenceCloud, "put") @@ -342,7 +346,7 @@ def test_update_space(self, mock_put, confluence_cloud): space_data = {"name": "Updated Space"} mock_put.return_value = {"id": "TEST", "name": "Updated Space"} result = confluence_cloud.update_space("TEST", space_data) - mock_put.assert_called_once_with("spaces/TEST", data=space_data, **{}) + mock_put.assert_called_once_with("api/v2/spaces/TEST", data=space_data, **{}) assert result == {"id": "TEST", "name": "Updated Space"} @patch.object(ConfluenceCloud, "delete") @@ -350,7 +354,7 @@ def test_delete_space(self, mock_delete, confluence_cloud): """delete_space calls the v2 plural endpoint.""" mock_delete.return_value = {"success": True} result = confluence_cloud.delete_space("TEST") - mock_delete.assert_called_once_with("spaces/TEST", **{}) + mock_delete.assert_called_once_with("api/v2/spaces/TEST", **{}) assert result == {"success": True} @patch.object(ConfluenceCloud, "get") @@ -358,7 +362,7 @@ def test_get_space_content(self, mock_get, confluence_cloud): """get_space_content calls the v2 plural endpoint.""" mock_get.return_value = {"results": [{"id": "123", "title": "Page in Space"}]} result = confluence_cloud.get_space_content("TEST") - mock_get.assert_called_once_with("spaces/TEST/content", **{}) + mock_get.assert_called_once_with("api/v2/spaces/TEST/content", **{}) assert result == {"results": [{"id": "123", "title": "Page in Space"}]} # User Management Tests @@ -367,7 +371,7 @@ def test_get_users(self, mock_get, confluence_cloud): """Test get_users method.""" mock_get.return_value = {"results": [{"id": "user1", "name": "Test User"}]} result = confluence_cloud.get_users() - mock_get.assert_called_once_with("user", **{}) + mock_get.assert_called_once_with("rest/api/user", **{}) assert result == {"results": [{"id": "user1", "name": "Test User"}]} @patch.object(ConfluenceCloud, "get") @@ -375,7 +379,7 @@ def test_get_user(self, mock_get, confluence_cloud): """Test get_user method.""" mock_get.return_value = {"id": "user1", "name": "Test User"} result = confluence_cloud.get_user("user1") - mock_get.assert_called_once_with("user/user1", **{}) + mock_get.assert_called_once_with("rest/api/user/user1", **{}) assert result == {"id": "user1", "name": "Test User"} @patch.object(ConfluenceCloud, "get") @@ -383,7 +387,7 @@ def test_get_current_user(self, mock_get, confluence_cloud): """Test get_current_user method.""" mock_get.return_value = {"id": "current", "name": "Current User"} result = confluence_cloud.get_current_user() - mock_get.assert_called_once_with("user/current", **{}) + mock_get.assert_called_once_with("rest/api/user/current", **{}) assert result == {"id": "current", "name": "Current User"} # Group Management Tests @@ -433,7 +437,7 @@ def test_get_labels(self, mock_get, confluence_cloud): """Test get_labels method.""" mock_get.return_value = {"results": [{"id": "label1", "name": "Test Label"}]} result = confluence_cloud.get_labels() - mock_get.assert_called_once_with("label", **{}) + mock_get.assert_called_once_with("rest/api/label", **{}) assert result == {"results": [{"id": "label1", "name": "Test Label"}]} @patch.object(ConfluenceCloud, "get") @@ -441,7 +445,7 @@ def test_get_content_labels(self, mock_get, confluence_cloud): """Test get_content_labels method.""" mock_get.return_value = {"results": [{"id": "label1", "name": "Test Label"}]} result = confluence_cloud.get_content_labels("123") - mock_get.assert_called_once_with("content/123/label", **{}) + mock_get.assert_called_once_with("rest/api/content/123/label", **{}) assert result == {"results": [{"id": "label1", "name": "Test Label"}]} @patch.object(ConfluenceCloud, "post") @@ -450,7 +454,7 @@ def test_add_content_labels(self, mock_post, confluence_cloud): label_data = {"name": "New Label"} mock_post.return_value = {"id": "label2", "name": "New Label"} result = confluence_cloud.add_content_labels("123", label_data) - mock_post.assert_called_once_with("content/123/label", data=label_data, **{}) + mock_post.assert_called_once_with("rest/api/content/123/label", data=label_data, **{}) assert result == {"id": "label2", "name": "New Label"} @patch.object(ConfluenceCloud, "delete") @@ -458,7 +462,7 @@ def test_remove_content_label(self, mock_delete, confluence_cloud): """Test remove_content_label method.""" mock_delete.return_value = {"success": True} result = confluence_cloud.remove_content_label("123", "label1") - mock_delete.assert_called_once_with("content/123/label/label1", **{}) + mock_delete.assert_called_once_with("rest/api/content/123/label/label1", **{}) assert result == {"success": True} # Attachment Management Tests @@ -467,7 +471,7 @@ def test_get_attachments(self, mock_get, confluence_cloud): """Test get_attachments method.""" mock_get.return_value = {"results": [{"id": "att1", "title": "Test Attachment"}]} result = confluence_cloud.get_attachments("123") - mock_get.assert_called_once_with("content/123/child/attachment", **{}) + mock_get.assert_called_once_with("rest/api/content/123/child/attachment", **{}) assert result == {"results": [{"id": "att1", "title": "Test Attachment"}]} @patch.object(ConfluenceCloud, "get") @@ -475,7 +479,7 @@ def test_get_attachment(self, mock_get, confluence_cloud): """Test get_attachment method.""" mock_get.return_value = {"id": "att1", "title": "Test Attachment"} result = confluence_cloud.get_attachment("att1") - mock_get.assert_called_once_with("content/att1", **{}) + mock_get.assert_called_once_with("rest/api/content/att1", **{}) assert result == {"id": "att1", "title": "Test Attachment"} @patch.object(ConfluenceCloud, "post") @@ -484,7 +488,7 @@ def test_create_attachment(self, mock_post, confluence_cloud): attachment_data = {"title": "New Attachment"} mock_post.return_value = {"id": "att2", "title": "New Attachment"} result = confluence_cloud.create_attachment("123", attachment_data) - mock_post.assert_called_once_with("content/123/child/attachment", data=attachment_data, **{}) + mock_post.assert_called_once_with("rest/api/content/123/child/attachment", data=attachment_data, **{}) assert result == {"id": "att2", "title": "New Attachment"} @patch.object(ConfluenceCloud, "put") @@ -493,7 +497,7 @@ def test_update_attachment(self, mock_put, confluence_cloud): attachment_data = {"title": "Updated Attachment"} mock_put.return_value = {"id": "att1", "title": "Updated Attachment"} result = confluence_cloud.update_attachment("att1", attachment_data) - mock_put.assert_called_once_with("content/att1", data=attachment_data, **{}) + mock_put.assert_called_once_with("rest/api/content/att1", data=attachment_data, **{}) assert result == {"id": "att1", "title": "Updated Attachment"} @patch.object(ConfluenceCloud, "delete") @@ -501,7 +505,7 @@ def test_delete_attachment(self, mock_delete, confluence_cloud): """Test delete_attachment method.""" mock_delete.return_value = {"success": True} result = confluence_cloud.delete_attachment("att1") - mock_delete.assert_called_once_with("content/att1", **{}) + mock_delete.assert_called_once_with("rest/api/content/att1", **{}) assert result == {"success": True} # Comment Management Tests @@ -510,7 +514,7 @@ def test_get_comments(self, mock_get, confluence_cloud): """Test get_comments method.""" mock_get.return_value = {"results": [{"id": "comment1", "text": "Test Comment"}]} result = confluence_cloud.get_comments("123") - mock_get.assert_called_once_with("content/123/child/comment", **{}) + mock_get.assert_called_once_with("rest/api/content/123/child/comment", **{}) assert result == {"results": [{"id": "comment1", "text": "Test Comment"}]} @patch.object(ConfluenceCloud, "get") @@ -518,7 +522,7 @@ def test_get_comment(self, mock_get, confluence_cloud): """Test get_comment method.""" mock_get.return_value = {"id": "comment1", "text": "Test Comment"} result = confluence_cloud.get_comment("comment1") - mock_get.assert_called_once_with("content/comment1", **{}) + mock_get.assert_called_once_with("rest/api/content/comment1", **{}) assert result == {"id": "comment1", "text": "Test Comment"} @patch.object(ConfluenceCloud, "post") @@ -527,7 +531,7 @@ def test_create_comment(self, mock_post, confluence_cloud): comment_data = {"text": "New Comment"} mock_post.return_value = {"id": "comment2", "text": "New Comment"} result = confluence_cloud.create_comment("123", comment_data) - mock_post.assert_called_once_with("content/123/child/comment", data=comment_data, **{}) + mock_post.assert_called_once_with("rest/api/content/123/child/comment", data=comment_data, **{}) assert result == {"id": "comment2", "text": "New Comment"} @patch.object(ConfluenceCloud, "put") @@ -536,7 +540,7 @@ def test_update_comment(self, mock_put, confluence_cloud): comment_data = {"text": "Updated Comment"} mock_put.return_value = {"id": "comment1", "text": "Updated Comment"} result = confluence_cloud.update_comment("comment1", comment_data) - mock_put.assert_called_once_with("content/comment1", data=comment_data, **{}) + mock_put.assert_called_once_with("rest/api/content/comment1", data=comment_data, **{}) assert result == {"id": "comment1", "text": "Updated Comment"} @patch.object(ConfluenceCloud, "delete") @@ -544,7 +548,7 @@ def test_delete_comment(self, mock_delete, confluence_cloud): """Test delete_comment method.""" mock_delete.return_value = {"success": True} result = confluence_cloud.delete_comment("comment1") - mock_delete.assert_called_once_with("content/comment1", **{}) + mock_delete.assert_called_once_with("rest/api/content/comment1", **{}) assert result == {"success": True} # Search Tests @@ -553,7 +557,7 @@ def test_search_content(self, mock_get, confluence_cloud): """Test search_content method.""" mock_get.return_value = {"results": [{"id": "123", "title": "Search Result"}]} result = confluence_cloud.search_content("type=page") - mock_get.assert_called_once_with("content/search", params={"cql": "type=page", **{}}) + mock_get.assert_called_once_with("rest/api/content/search", params={"cql": "type=page", **{}}) assert result == {"results": [{"id": "123", "title": "Search Result"}]} @patch.object(ConfluenceCloud, "get") @@ -561,7 +565,7 @@ def test_search_spaces(self, mock_get, confluence_cloud): """Test search_spaces method.""" mock_get.return_value = {"results": [{"id": "TEST", "name": "Test Space"}]} result = confluence_cloud.search_spaces("test") - mock_get.assert_called_once_with("space/search", params={"query": "test", **{}}) + mock_get.assert_called_once_with("rest/api/space/search", params={"query": "test", **{}}) assert result == {"results": [{"id": "TEST", "name": "Test Space"}]} # Page Properties Tests @@ -570,7 +574,7 @@ def test_get_content_properties(self, mock_get, confluence_cloud): """Test get_content_properties method.""" mock_get.return_value = {"results": [{"key": "prop1", "value": "value1"}]} result = confluence_cloud.get_content_properties("123") - mock_get.assert_called_once_with("content/123/property", **{}) + mock_get.assert_called_once_with("rest/api/content/123/property", **{}) assert result == {"results": [{"key": "prop1", "value": "value1"}]} @patch.object(ConfluenceCloud, "get") @@ -578,7 +582,7 @@ def test_get_content_property(self, mock_get, confluence_cloud): """Test get_content_property method.""" mock_get.return_value = {"key": "prop1", "value": "value1"} result = confluence_cloud.get_content_property("123", "prop1") - mock_get.assert_called_once_with("content/123/property/prop1", **{}) + mock_get.assert_called_once_with("rest/api/content/123/property/prop1", **{}) assert result == {"key": "prop1", "value": "value1"} @patch.object(ConfluenceCloud, "post") @@ -587,7 +591,7 @@ def test_create_content_property(self, mock_post, confluence_cloud): property_data = {"key": "prop2", "value": "value2"} mock_post.return_value = {"key": "prop2", "value": "value2"} result = confluence_cloud.create_content_property("123", property_data) - mock_post.assert_called_once_with("content/123/property", data=property_data, **{}) + mock_post.assert_called_once_with("rest/api/content/123/property", data=property_data, **{}) assert result == {"key": "prop2", "value": "value2"} @patch.object(ConfluenceCloud, "put") @@ -596,7 +600,7 @@ def test_update_content_property(self, mock_put, confluence_cloud): property_data = {"value": "updated_value"} mock_put.return_value = {"key": "prop1", "value": "updated_value"} result = confluence_cloud.update_content_property("123", "prop1", property_data) - mock_put.assert_called_once_with("content/123/property/prop1", data=property_data, **{}) + mock_put.assert_called_once_with("rest/api/content/123/property/prop1", data=property_data, **{}) assert result == {"key": "prop1", "value": "updated_value"} @patch.object(ConfluenceCloud, "delete") @@ -604,7 +608,7 @@ def test_delete_content_property(self, mock_delete, confluence_cloud): """Test delete_content_property method.""" mock_delete.return_value = {"success": True} result = confluence_cloud.delete_content_property("123", "prop1") - mock_delete.assert_called_once_with("content/123/property/prop1", **{}) + mock_delete.assert_called_once_with("rest/api/content/123/property/prop1", **{}) assert result == {"success": True} # Template Tests @@ -613,7 +617,7 @@ def test_get_templates(self, mock_get, confluence_cloud): """Test get_templates method.""" mock_get.return_value = {"results": [{"id": "template1", "name": "Test Template"}]} result = confluence_cloud.get_templates() - mock_get.assert_called_once_with("template", **{}) + mock_get.assert_called_once_with("rest/api/template", **{}) assert result == {"results": [{"id": "template1", "name": "Test Template"}]} @patch.object(ConfluenceCloud, "get") @@ -621,7 +625,7 @@ def test_get_template(self, mock_get, confluence_cloud): """Test get_template method.""" mock_get.return_value = {"id": "template1", "name": "Test Template"} result = confluence_cloud.get_template("template1") - mock_get.assert_called_once_with("template/template1", **{}) + mock_get.assert_called_once_with("rest/api/template/template1", **{}) assert result == {"id": "template1", "name": "Test Template"} # Analytics Tests @@ -630,7 +634,7 @@ def test_get_content_analytics(self, mock_get, confluence_cloud): """Test get_content_analytics method.""" mock_get.return_value = {"views": 100, "likes": 10} result = confluence_cloud.get_content_analytics("123") - mock_get.assert_called_once_with("content/123/analytics", **{}) + mock_get.assert_called_once_with("rest/api/content/123/analytics", **{}) assert result == {"views": 100, "likes": 10} @patch.object(ConfluenceCloud, "get") @@ -638,7 +642,7 @@ def test_get_space_analytics(self, mock_get, confluence_cloud): """Test get_space_analytics method.""" mock_get.return_value = {"totalPages": 50, "totalUsers": 25} result = confluence_cloud.get_space_analytics("TEST") - mock_get.assert_called_once_with("space/TEST/analytics", **{}) + mock_get.assert_called_once_with("rest/api/space/TEST/analytics", **{}) assert result == {"totalPages": 50, "totalUsers": 25} # Export Tests @@ -647,7 +651,7 @@ def test_export_content(self, mock_get, confluence_cloud): """Test export_content method.""" mock_get.return_value = {"exportData": "base64_encoded_content"} result = confluence_cloud.export_content("123") - mock_get.assert_called_once_with("content/123/export", **{}) + mock_get.assert_called_once_with("rest/api/content/123/export", **{}) assert result == {"exportData": "base64_encoded_content"} @patch.object(ConfluenceCloud, "get") @@ -655,7 +659,7 @@ def test_export_space(self, mock_get, confluence_cloud): """Test export_space method.""" mock_get.return_value = {"exportData": "base64_encoded_space"} result = confluence_cloud.export_space("TEST") - mock_get.assert_called_once_with("space/TEST/export", **{}) + mock_get.assert_called_once_with("rest/api/space/TEST/export", **{}) assert result == {"exportData": "base64_encoded_space"} # Utility Methods Tests @@ -664,7 +668,7 @@ def test_get_metadata(self, mock_get, confluence_cloud): """Test get_metadata method.""" mock_get.return_value = {"version": "2.0", "buildNumber": "123"} result = confluence_cloud.get_metadata() - mock_get.assert_called_once_with("metadata", **{}) + mock_get.assert_called_once_with("rest/api/metadata", **{}) assert result == {"version": "2.0", "buildNumber": "123"} @patch.object(ConfluenceCloud, "get") @@ -672,7 +676,7 @@ def test_get_health(self, mock_get, confluence_cloud): """Test get_health method.""" mock_get.return_value = {"status": "healthy"} result = confluence_cloud.get_health() - mock_get.assert_called_once_with("health", **{}) + mock_get.assert_called_once_with("rest/api/health", **{}) assert result == {"status": "healthy"} # Pagination Tests for _get_paged (tested directly since Cloud has no paginated public methods yet) diff --git a/tests/confluence/test_confluence_cloud_urls.py b/tests/confluence/test_confluence_cloud_urls.py new file mode 100644 index 000000000..e7ee8b56f --- /dev/null +++ b/tests/confluence/test_confluence_cloud_urls.py @@ -0,0 +1,77 @@ +# coding=utf-8 +"""Regression tests for legacy Confluence Cloud URL construction. + +These tests spy on the transport layer (``AtlassianRestAPI.request``) instead +of mocking ``Cloud.get`` so they verify the full URL — scheme, host, ``/wiki`` +context path, and API root prefixes. They exist because the 3.41 split of the +monolithic ``confluence.py`` silently dropped both the ``/wiki`` rewrite and +the ``rest/api`` prefixes from every legacy Cloud method. +""" + +from unittest.mock import MagicMock, patch + +from atlassian.confluence import Confluence +from atlassian.confluence.cloud import Cloud + + +def _spy(): + """Return (sent_urls, request spy) capturing fully joined URLs.""" + sent = [] + + def fake_request(self, method, path="/", **kwargs): + sent.append(self.url_joiner(None if kwargs.get("absolute") else self.url, path)) + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"results": []} + return response + + return sent, fake_request + + +def _cloud_client(url="https://example.atlassian.net"): + client = Confluence(url=url, username="u", password="p") + assert isinstance(client._impl, Cloud) + return client._impl + + +class TestCloudUrlContext: + """Tenant URLs must carry the /wiki context path exactly once.""" + + def test_bare_tenant_url_gets_wiki_suffix(self): + assert _cloud_client().url == "https://example.atlassian.net/wiki" + + def test_explicit_wiki_url_is_not_doubled(self): + assert _cloud_client("https://example.atlassian.net/wiki").url == "https://example.atlassian.net/wiki" + + def test_gateway_url_is_not_rewritten(self): + client = Confluence(url="https://api.atlassian.com/ex/confluence/abc123", username="u", password="p") + assert client.url == "https://api.atlassian.com/ex/confluence/abc123" + + +class TestCloudEndpointPaths: + """Legacy Cloud methods must hit versioned API roots under /wiki.""" + + def test_v1_content_methods_use_rest_api(self): + impl = _cloud_client() + sent, spy = _spy() + with patch("atlassian.rest_client.AtlassianRestAPI.request", autospec=True, side_effect=spy): + impl.get_content("123") + impl.get_page_by_title("SPACE", "Title") + impl.get_current_user() + assert sent[0] == "https://example.atlassian.net/wiki/rest/api/content/123" + assert sent[1] == "https://example.atlassian.net/wiki/rest/api/content" + assert sent[2] == "https://example.atlassian.net/wiki/rest/api/user/current" + + def test_v2_space_methods_use_api_v2(self): + impl = _cloud_client() + sent, spy = _spy() + with patch("atlassian.rest_client.AtlassianRestAPI.request", autospec=True, side_effect=spy): + impl.get_spaces() + assert sent[0] == "https://example.atlassian.net/wiki/api/v2/spaces" + + def test_search_uses_rest_api_content_search(self): + impl = _cloud_client() + sent, spy = _spy() + with patch("atlassian.rest_client.AtlassianRestAPI.request", autospec=True, side_effect=spy): + impl.search_content("type=page") + assert sent[0] == "https://example.atlassian.net/wiki/rest/api/content/search" diff --git a/tests/crowd/test_crowd.py b/tests/crowd/test_crowd.py index ebf0a15d9..e9cbe77b1 100644 --- a/tests/crowd/test_crowd.py +++ b/tests/crowd/test_crowd.py @@ -150,7 +150,7 @@ def test_user_password_and_reminder_methods(mock_get, mock_delete, mock_post): assert mock_post.call_args.kwargs["params"] == {"username": "ada"} assert mock_post.call_args.kwargs["data"] == {"newName": "ada2"} - crowd.user_expire_all_passwords() + crowd.user_expire_all_passwords(confirm=True) assert mock_post.call_args.args[0].endswith("usermanagement/latest/user/expire-all-passwords") assert mock_post.call_args.kwargs["params"] == {"confirm": "true"} diff --git a/tests/test_crowd.py b/tests/test_crowd.py index 968ce415f..a1e306d52 100644 --- a/tests/test_crowd.py +++ b/tests/test_crowd.py @@ -312,40 +312,41 @@ def test_user_rename(self, mock_post): def test_user_expire_all_passwords(self, mock_post): """Test user_expire_all_passwords method.""" mock_post.return_value = {} - self.crowd.user_expire_all_passwords() + self.crowd.user_expire_all_passwords(confirm=True) mock_post.assert_called_once_with( "/rest/usermanagement/latest/user/expire-all-passwords", params={"confirm": "true"}, ) - @patch.object(Crowd, "post") - def test_user_expire_all_passwords_false(self, mock_post): - """Test user_expire_all_passwords method with confirm=False.""" - mock_post.return_value = {} - self.crowd.user_expire_all_passwords(confirm=False) - mock_post.assert_called_once_with( - "/rest/usermanagement/latest/user/expire-all-passwords", - params={"confirm": "false"}, - ) + def test_user_expire_all_passwords_requires_confirmation(self): + """Test user_expire_all_passwords refuses to run without explicit confirmation.""" + with self.assertRaises(ValueError): + self.crowd.user_expire_all_passwords() + with self.assertRaises(ValueError): + self.crowd.user_expire_all_passwords(confirm=False) @patch.object(Crowd, "get") def test_user_avatar(self, mock_get): - """Test user_avatar method.""" + """Test user_avatar method returns raw image bytes.""" mock_get.return_value = b"avatar-data" - self.crowd.user_avatar("john") + result = self.crowd.user_avatar("john") + self.assertEqual(result, b"avatar-data") mock_get.assert_called_once_with( "/rest/usermanagement/latest/user/avatar", params={"username": "john"}, + not_json_response=True, ) @patch.object(Crowd, "get") def test_user_avatar_with_size(self, mock_get): """Test user_avatar method with size parameter.""" mock_get.return_value = b"avatar-data" - self.crowd.user_avatar("john", size=64) + result = self.crowd.user_avatar("john", size=64) + self.assertEqual(result, b"avatar-data") mock_get.assert_called_once_with( "/rest/usermanagement/latest/user/avatar", params={"username": "john", "s": 64}, + not_json_response=True, ) @patch.object(Crowd, "get") @@ -1905,7 +1906,6 @@ def test_search_audit_log_default_query(self, mock_post): params={"start": 0, "limit": 99999}, data={}, ) - @patch.object(Crowd, "post") def test_get_audit_log_filter_values(self, mock_post): """Test get_audit_log_filter_values method.""" @@ -1913,6 +1913,7 @@ def test_get_audit_log_filter_values(self, mock_post): mock_post.assert_called_once_with( "/rest/admin/1.0/auditlog/query/filter", params={"start": 9, "limit": 90, "projection": "AUDITED_USERS", "search": "john"}, + data={}, ) @patch.object(Crowd, "post") @@ -1922,6 +1923,7 @@ def test_get_audit_log_filter_values_no_filters(self, mock_post): mock_post.assert_called_once_with( "/rest/admin/1.0/auditlog/query/filter", params={"start": 0, "limit": 99999}, + data={}, ) @@ -2168,7 +2170,7 @@ def test_update_plugin_license(self, mock_put): """Test update_plugin_license method.""" self.crowd.update_plugin_license("plugin1", "raw-license-text") mock_put.assert_called_once_with( - "/plugins/1.0/plugin1/license", + "rest/plugins/1.0/plugin1/license", data={"rawLicense": "raw-license-text"}, headers={ "X-Atlassian-Token": "no-check", diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 7b59a6f2e..40a0fe80e 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -125,7 +125,9 @@ def test_get_filters(self, mock_get): """Test get_filters method.""" mock_get.return_value = [{"id": "1", "name": "Filter 1"}] result = self.portfolio.get_filters("test query") - mock_get.assert_called_once_with("rest/roadmap/1.0/system/filters.json?queryString=test query") + mock_get.assert_called_once_with( + "rest/roadmap/1.0/system/filters.json", params={"queryString": "test query"} + ) self.assertEqual(len(result), 1) @patch.object(Portfolio, "get") diff --git a/tests/test_rest_client.py b/tests/test_rest_client.py index 13447c6fe..f135bd73b 100644 --- a/tests/test_rest_client.py +++ b/tests/test_rest_client.py @@ -5,7 +5,7 @@ import io from base64 import b64decode -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest @@ -555,7 +555,7 @@ def fake_sleep(delay): handler = api._retry_handler() future_delay = 10 - future_date = datetime.utcnow().replace(tzinfo=None) + timedelta(seconds=future_delay) + future_date = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(seconds=future_delay) retry_after_value = future_date.strftime("%a, %d %b %Y %H:%M:%S GMT") response = SimpleNamespace(headers={"Retry-After": retry_after_value}, status_code=429)