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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,16 @@ level directory.

pip install . --upgrade

The following command builds a package and uploads it to PIP repository.
Build and validate distributions before uploading them to PyPI. Twine 6 or
newer is required because current setuptools releases generate Core Metadata
2.4, which older Twine versions cannot validate.

::

python setup.py sdist upload

Alternative way

::

python -m pip install build twine
python -m pip install --upgrade build "twine>=6.0.0"
python -m build
twine upload dist/*
python -m twine check dist/*
python -m twine upload dist/*

An alternative way you can use next command

Expand Down
2 changes: 1 addition & 1 deletion atlassian/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4.0.8
5.0.1
5 changes: 2 additions & 3 deletions atlassian/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .bitbucket import Bitbucket as Stash
from .cloud_admin import CloudAdminOrgs, CloudAdminUsers
from .confluence import Confluence, ConfluenceBase, ConfluenceCloud, ConfluenceServer
from .confluence.cloud.cloud import ConfluenceCloud as ConfluenceV2
from .crowd import Crowd
from .insight import Insight
from .insight import Insight as Assets # used for Insight on-premise
Expand All @@ -22,13 +23,11 @@

# Confluence REST API v2 client. The existing ``Confluence`` class remains
# the backwards-compatible v1/v2 URL-dispatching client.
ConfluenceV2 = ConfluenceCloud


def create_confluence(url, *args, api_version=1, **kwargs):
"""Create a version-aware Confluence client."""
return ConfluenceBase.factory(url, *args, api_version=api_version, **kwargs)


__all__ = [
"Confluence",
"ConfluenceBase",
Expand Down
21 changes: 19 additions & 2 deletions atlassian/bamboo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,8 +1203,25 @@ def agent_capabilities(self, agent_id, include_shared=True):
params={"includeShared": include_shared},
)

def activity(self):
return self.get("build/admin/ajax/getDashboardSummary.action")
def activity(self, busy=None):
"""Return active online agents and their current build activity.

The former dashboard AJAX endpoint was an internal Bamboo UI endpoint
and is not present in current Bamboo releases. The supported agent
REST resource exposes ``active`` and ``busy`` for each online agent.

:param busy: Optional filter for busy (``True``) or idle (``False``)
agents. By default, return all active online agents.
:return: List of active agent dictionaries, including ``busy``.
"""
agents = self.agent_status(online=True)
if not isinstance(agents, list):
return agents

active_agents = [agent for agent in agents if agent.get("active", agent.get("online", False))]
if busy is None:
return active_agents
return [agent for agent in active_agents if agent.get("busy") is busy]

def get_custom_expiry(self, limit=25):
"""
Expand Down
33 changes: 33 additions & 0 deletions atlassian/bitbucket/cloud/repositories/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# coding=utf-8

from urllib.parse import quote

from requests import HTTPError

from .branchRestrictions import BranchRestrictions
Expand Down Expand Up @@ -377,6 +379,37 @@ def get_avatar(self):
"""The repository avatar"""
return self.get(self.get_link("avatar"), absolute=True)

def get_source_file(self, commit, path):
"""Return the raw bytes of a file at a branch, tag, or commit.

:param commit: Branch name, tag, or commit hash to read from.
:param path: Repository-relative path of the file.
:return: Raw file content as bytes.

API docs:
https://developer.atlassian.com/cloud/bitbucket/rest/api-group-source/#api-repositories-workspace-repo-slug-src-commit-path-get
"""
source_path = quote(path.strip("/"), safe="/")
if not source_path:
raise ValueError("path must identify a file")
return self.get(
f"src/{quote(str(commit), safe='')}/{source_path}",
not_json_response=True,
)

def get_source_directory(self, commit, path=""):
"""Return the JSON directory listing at a branch, tag, or commit.

:param commit: Branch name, tag, or commit hash to browse.
:param path: Optional repository-relative directory path.
:return: Paginated Bitbucket directory listing.
"""
source_path = quote(path.strip("/"), safe="/")
endpoint = f"src/{quote(str(commit), safe='')}"
if source_path:
endpoint = f"{endpoint}/{source_path}"
return self.get(endpoint)

@property
def branch_restrictions(self):
"""The repository branch restrictions"""
Expand Down
22 changes: 15 additions & 7 deletions atlassian/bitbucket/cloud/workspaces/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,25 @@ def each(self, role=None, q=None, sort=None):

:return: A generator for the Workspace objects

API docs: https://developer.atlassian.com/bitbucket/api/2/reference/resource/workspaces#get.
The former ``/workspaces`` listing endpoint is deprecated. This method
uses ``/user/workspaces`` and resolves each returned workspace to keep
yielding :class:`Workspace` objects as before.

API docs: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-user-workspaces-get.
"""
params = {}
if role is not None:
params["role"] = role
if q is not None:
params["q"] = q
# The replacement endpoint does not support the legacy ``role`` and
# ``q`` filters. They are intentionally not forwarded because doing so
# would produce an invalid request.
if sort is not None:
params["sort"] = sort
for workspace in self._get_paged(None, params):
yield self.__get_object(workspace)
user_workspaces_url = f"{self.url.rsplit('/', 1)[0]}/user/workspaces"
for workspace_access in self._get_paged(user_workspaces_url, params=params, absolute=True):
workspace_data = workspace_access.get("workspace", workspace_access)
workspace_id = workspace_data.get("slug") or workspace_data.get("uuid")
if workspace_id is None:
raise ValueError("Bitbucket returned a workspace without a slug or UUID")
yield self.get(workspace_id)

return

Expand Down
16 changes: 11 additions & 5 deletions atlassian/confluence/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

from ..confluence_base import ConfluenceBase
from .cloud import Cloud as LegacyConfluenceCloud
from .cloud.cloud import ConfluenceCloud
from .server import Server as ConfluenceServer
from .base import ConfluenceBase as LegacyConfluenceBase

Expand All @@ -20,10 +19,13 @@ class Confluence(LegacyConfluenceBase):

def __new__(cls, url, *args, **kwargs):
api_version = kwargs.get("api_version")
if api_version in {1, 2}:
versioned_kwargs = dict(kwargs)
versioned_kwargs.pop("api_version")
return ConfluenceBase(url, *args, api_version=api_version, **versioned_kwargs)
# Scoped API tokens use the Atlassian API gateway and only support the
# Cloud v2 endpoints. Route gateway URLs there automatically so callers
# do not have to know the internal client split.
if api_version in (1, 2) or ConfluenceBase._is_api_gateway_url(url):
from .cloud.cloud import ConfluenceCloud as VersionedConfluenceCloud

return VersionedConfluenceCloud(url, *args, **kwargs)
return super().__new__(cls)

def __init__(self, url, *args, **kwargs):
Expand Down Expand Up @@ -58,3 +60,7 @@ def __getattr__(self, attr):
"ConfluenceServer",
"ConfluenceBase",
]

# ``ConfluenceCloud`` is the established Cloud REST client. The separate v2
# implementation is intentionally exported as ``atlassian.ConfluenceV2``.
ConfluenceCloud = LegacyConfluenceCloud
61 changes: 60 additions & 1 deletion atlassian/confluence/cloud/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# coding=utf-8

import logging
import re
import time
from .base import ConfluenceCloudBase
import requests
from requests import HTTPError
from atlassian.errors import (
ApiError,
ApiNotFoundError,
)
from .cloud import ConfluenceCloud
from .cloud import ConfluenceCloud # noqa: F401

log = logging.getLogger(__name__)

Expand All @@ -27,6 +31,61 @@ def __init__(self, url="https://api.atlassian.com/", *args, **kwargs):
url = url.strip("/")
super(Cloud, self).__init__(url, *args, **kwargs)

def _cloud_wiki_url(self, path):
"""Return an absolute Cloud URL under the site's ``/wiki`` context."""
base_url = self.url.rstrip("/")
if not base_url.endswith("/wiki"):
base_url += "/wiki"
return self.url_joiner(base_url, path)

def get_pdf_download_url_for_confluence_cloud(self, url):
"""Start a Cloud PDF export and return its signed download URL.

Confluence Cloud creates PDF exports asynchronously. The legacy task
endpoint was removed; the current task state is available from the V2
``pdfexporttask`` endpoint.
"""
try:
response = self.get(url, headers=self.form_token_headers, not_json_response=True, absolute=True)
task_match = re.search(rb'name="ajs-taskId"\s+content="([^"]+)"', response)
if not task_match:
log.error("Could not find the PDF export task ID in the response")
return None

task_id = task_match.group(1).decode("utf-8", errors="ignore")
poll_url = self._cloud_wiki_url(f"api/v2/pdfexporttask/progress/{task_id}")

while True:
log.info("Check if PDF export task has completed.")
progress_response = self.get(poll_url, absolute=True) or {}
task_state = progress_response.get("state")
if task_state == "FAILED" or progress_response.get("status") == "failed":
log.error("PDF conversion was not successful.")
return None

download_url = progress_response.get("result")
if isinstance(download_url, str) and download_url:
return self._cloud_wiki_url(download_url) if download_url.startswith("/") else download_url

percentage_complete = int(progress_response.get("progress", 0))
log.info("%s%% - %s", percentage_complete, task_state)
time.sleep(3)
except (AttributeError, TypeError, ValueError) as error:
log.error("Could not initiate or poll the PDF export: %s", error)
return None

def get_page_as_pdf(self, page_id):
"""Export a Cloud page as PDF using Confluence's asynchronous exporter."""
export_url = self._cloud_wiki_url(f"spaces/flyingpdf/pdfpageexport.action?pageId={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")
return requests.get(download_url, timeout=75).content

def export_page(self, page_id):
"""Alias for :meth:`get_page_as_pdf`."""
return self.get_page_as_pdf(page_id)

# Content Management
def get_content(self, content_id, **kwargs):
"""Get content by ID."""
Expand Down
Loading
Loading