From b6111daedcf40c58029fef636aa98bb0b3f5d700 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Sun, 30 Jun 2024 21:26:14 -0400 Subject: [PATCH 01/10] removing BountySource from readme --- README.rst | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 9c77432ff..413227c52 100644 --- a/README.rst +++ b/README.rst @@ -22,15 +22,10 @@ Jira Python Library .. image:: https://codecov.io/gh/pycontribs/jira/branch/main/graph/badge.svg :target: https://codecov.io/gh/pycontribs/jira -.. image:: https://img.shields.io/bountysource/team/pycontribs/activity.svg - :target: https://www.bountysource.com/teams/pycontribs/issues?tracker_ids=3650997 - This library eases the use of the Jira REST API from Python and it has been used in production for years. -As this is an open-source project that is community maintained, do not be surprised if some bugs or features are not implemented quickly enough. You are always welcomed to use BountySource_ to motivate others to help. - -.. _BountySource: https://www.bountysource.com/teams/pycontribs/issues?tracker_ids=3650997 +As this is an open-source project that is community maintained, do not be surprised if some bugs or features are not implemented quickly enough. Quickstart From 8668a57db56415696862c1cdbd6a25e33f4060a3 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Sat, 24 Aug 2024 11:46:27 +0100 Subject: [PATCH 02/10] Fix several bugs reported by linters (#1882) --- .config/dictionary.txt | 1 + .github/release-drafter.yml | 4 +- .github/workflows/ack.yml | 4 +- .github/workflows/push.yml | 4 +- .pre-commit-config.yaml | 11 +-- jira/client.py | 133 +++++++++++++++++++--------------- jira/config.py | 3 +- jira/exceptions.py | 10 +-- jira/resources.py | 118 ++++++++++++++++-------------- pyproject.toml | 10 +++ tests/resources/test_epic.py | 2 +- tests/resources/test_issue.py | 8 +- tests/test_exceptions.py | 4 +- tests/tests.py | 2 +- 14 files changed, 173 insertions(+), 141 deletions(-) create mode 100644 .config/dictionary.txt diff --git a/.config/dictionary.txt b/.config/dictionary.txt new file mode 100644 index 000000000..bf52b4c93 --- /dev/null +++ b/.config/dictionary.txt @@ -0,0 +1 @@ +assertIn diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 114b5fc80..dd5152eea 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,2 +1,2 @@ -# see https://github.com/ansible-community/devtools -_extends: ansible-community/devtools +# see https://github.com/ansible/team-devtools +_extends: ansible/team-devtools diff --git a/.github/workflows/ack.yml b/.github/workflows/ack.yml index 5880addda..958b0b647 100644 --- a/.github/workflows/ack.yml +++ b/.github/workflows/ack.yml @@ -1,4 +1,4 @@ -# See https://github.com/ansible-community/devtools/blob/main/.github/workflows/ack.yml +# See https://github.com/ansible/team-devtools/blob/main/.github/workflows/ack.yml name: ack on: pull_request_target: @@ -6,4 +6,4 @@ on: jobs: ack: - uses: ansible-community/devtools/.github/workflows/ack.yml@main + uses: ansible/team-devtools/.github/workflows/ack.yml@main diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index e8239f701..7fa9d2a58 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,4 +1,4 @@ -# See https://github.com/ansible-community/devtools/blob/main/.github/workflows/push.yml +# See https://github.com/ansible/team-devtools/blob/main/.github/workflows/push.yml name: push on: push: @@ -9,4 +9,4 @@ on: jobs: ack: - uses: ansible-community/devtools/.github/workflows/push.yml@main + uses: ansible/team-devtools/.github/workflows/push.yml@main diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67f50e68e..96be8bad6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace @@ -15,7 +15,7 @@ repos: - id: check-yaml files: .*\.(yaml|yml)$ - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell name: codespell @@ -27,7 +27,7 @@ repos: require_serial: false additional_dependencies: [] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.3.4" + rev: "v0.6.2" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -39,11 +39,8 @@ repos: - id: yamllint files: \.(yaml|yml)$ - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 + rev: v1.11.1 hooks: - id: mypy additional_dependencies: - types-requests - - types-pkg_resources - args: - [--no-strict-optional, --ignore-missing-imports, --show-error-codes] diff --git a/jira/client.py b/jira/client.py index ff311bcd6..aefae7cd1 100644 --- a/jira/client.py +++ b/jira/client.py @@ -208,7 +208,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def _field_worker( - fields: dict[str, Any] = None, **fieldargs: Any + fields: dict[str, Any] | None = None, **fieldargs: Any ) -> dict[str, dict[str, Any]] | dict[str, dict[str, str]]: if fields is not None: return {"fields": fields} @@ -221,7 +221,7 @@ def _field_worker( class ResultList(list, Generic[ResourceType]): def __init__( self, - iterable: Iterable = None, + iterable: Iterable | None = None, _startAt: int = 0, _maxResults: int = 0, _total: int | None = None, @@ -464,23 +464,23 @@ class JIRA: def __init__( self, - server: str = None, - options: dict[str, str | bool | Any] = None, + server: str | None = None, + options: dict[str, str | bool | Any] | None = None, basic_auth: tuple[str, str] | None = None, token_auth: str | None = None, - oauth: dict[str, Any] = None, - jwt: dict[str, Any] = None, + oauth: dict[str, Any] | None = None, + jwt: dict[str, Any] | None = None, kerberos=False, - kerberos_options: dict[str, Any] = None, + kerberos_options: dict[str, Any] | None = None, validate=False, get_server_info: bool = True, async_: bool = False, async_workers: int = 5, logging: bool = True, max_retries: int = 3, - proxies: Any = None, + proxies: Any | None = None, timeout: None | float | tuple[float, float] | tuple[float, None] | None = None, - auth: tuple[str, str] = None, + auth: tuple[str, str] | None = None, default_batch_sizes: dict[type[Resource], int | None] | None = None, ): """Construct a Jira client instance. @@ -559,7 +559,6 @@ def __init__( """ # force a copy of the tuple to be used in __del__() because # sys.version_info could have already been deleted in __del__() - self.sys_version_info = tuple(sys.version_info) if options is None: options = {} @@ -745,7 +744,8 @@ def close(self): # because other references are also in the process to be torn down, # see warning section in https://docs.python.org/2/reference/datamodel.html#object.__del__ pass - self._session = None + # TODO: https://github.com/pycontribs/jira/issues/1881 + self._session = None # type: ignore[arg-type,assignment] def _check_for_html_error(self, content: str): # Jira has the bad habit of returning errors in pages with 200 and embedding the @@ -771,7 +771,7 @@ def _fetch_pages( request_path: str, startAt: int = 0, maxResults: int = 50, - params: dict[str, Any] = None, + params: dict[str, Any] | None = None, base: str = JIRA_BASE_URL, use_post: bool = False, ) -> ResultList[ResourceType]: @@ -991,7 +991,7 @@ def async_do(self, size: int = 10): # non-resource def application_properties( - self, key: str = None + self, key: str | None = None ) -> dict[str, str] | list[dict[str, str]]: """Return the mutable server application properties. @@ -1065,7 +1065,7 @@ def add_attachment( self, issue: str | int, attachment: str | BufferedReader, - filename: str = None, + filename: str | None = None, ) -> Attachment: """Attach an attachment to an issue and returns a Resource for it. @@ -1089,8 +1089,7 @@ def add_attachment( attachment_io = attachment if isinstance(attachment, BufferedReader) and attachment.mode != "rb": self.log.warning( - "%s was not opened in 'rb' mode, attaching file may fail." - % attachment.name + f"{attachment.name} was not opened in 'rb' mode, attaching file may fail." ) fname = filename @@ -1570,10 +1569,10 @@ def favourite_filters(self) -> list[Filter]: def create_filter( self, - name: str = None, - description: str = None, - jql: str = None, - favourite: bool = None, + name: str | None = None, + description: str | None = None, + jql: str | None = None, + favourite: bool | None = None, ) -> Filter: """Create a new filter and return a filter Resource for it. @@ -1604,10 +1603,10 @@ def create_filter( def update_filter( self, filter_id, - name: str = None, - description: str = None, - jql: str = None, - favourite: bool = None, + name: str | None = None, + description: str | None = None, + jql: str | None = None, + favourite: bool | None = None, ): """Update a filter and return a filter Resource for it. @@ -1637,7 +1636,7 @@ def update_filter( # Groups - def group(self, id: str, expand: Any = None) -> Group: + def group(self, id: str, expand: Any | None = None) -> Group: """Get a group Resource from the server. Args: @@ -2010,7 +2009,10 @@ def service_desk(self, id: str) -> ServiceDesk: @no_type_check # FIXME: This function does not do what it wants to with fieldargs def create_customer_request( - self, fields: dict[str, Any] = None, prefetch: bool = True, **fieldargs + self, + fields: dict[str, Any] | None = None, + prefetch: bool = True, + **fieldargs, ) -> Issue: """Create a new customer request and return an issue Resource for it. @@ -3262,7 +3264,7 @@ def create_temp_project_avatar( filename: str, size: int, avatar_img: bytes, - contentType: str = None, + contentType: str | None = None, auto_confirm: bool = False, ): """Register an image file as a project avatar. @@ -3808,7 +3810,7 @@ def create_temp_user_avatar( filename: str, size: int, avatar_img: bytes, - contentType: Any = None, + contentType: Any | None = None, auto_confirm: bool = False, ): """Register an image file as a user avatar. @@ -3981,8 +3983,8 @@ def search_users( def search_allowed_users_for_issue( self, user: str, - issueKey: str = None, - projectKey: str = None, + issueKey: str | None = None, + projectKey: str | None = None, startAt: int = 0, maxResults: int = 50, ) -> ResultList: @@ -4014,9 +4016,9 @@ def create_version( self, name: str, project: str, - description: str = None, - releaseDate: Any = None, - startDate: Any = None, + description: str | None = None, + releaseDate: Any | None = None, + startDate: Any | None = None, archived: bool = False, released: bool = False, ) -> Version: @@ -4054,7 +4056,9 @@ def create_version( version = Version(self._options, self._session, raw=json_loads(r)) return version - def move_version(self, id: str, after: str = None, position: str = None) -> Version: + def move_version( + self, id: str, after: str | None = None, position: str | None = None + ) -> Version: """Move a version within a project's ordered version list and return a new version Resource for it. One, but not both, of ``after`` and ``position`` must be specified. @@ -4079,7 +4083,7 @@ def move_version(self, id: str, after: str = None, position: str = None) -> Vers version = Version(self._options, self._session, raw=json_loads(r)) return version - def version(self, id: str, expand: Any = None) -> Version: + def version(self, id: str, expand: Any | None = None) -> Version: """Get a version Resource. Args: @@ -4203,7 +4207,7 @@ def _create_oauth_session(self, oauth: dict[str, Any]): def _create_kerberos_session( self, - kerberos_options: dict[str, Any] = None, + kerberos_options: dict[str, Any] | None = None, ): if kerberos_options is None: kerberos_options = {} @@ -4216,8 +4220,9 @@ def _create_kerberos_session( mutual_authentication = DISABLED else: raise ValueError( - "Unknown value for mutual_authentication: %s" - % kerberos_options["mutual_authentication"] + "Unknown value for mutual_authentication: {}".format( + kerberos_options["mutual_authentication"] + ) ) self._session.auth = HTTPKerberosAuth( @@ -4249,7 +4254,7 @@ def _add_ssl_cert_verif_strategy_to_session(self): self._session.verify = ssl_cert @staticmethod - def _timestamp(dt: datetime.timedelta = None): + def _timestamp(dt: datetime.timedelta | None = None): t = datetime.datetime.utcnow() if dt is not None: t += dt @@ -4336,7 +4341,7 @@ def _get_latest_url(self, path: str, base: str = JIRA_BASE_URL) -> str: def _get_json( self, path: str, - params: dict[str, Any] = None, + params: dict[str, Any] | None = None, base: str = JIRA_BASE_URL, use_post: bool = False, ): @@ -4657,6 +4662,8 @@ def backup_complete(self) -> bool | None: self.log.warning("This functionality is not available in Server version") return None status = self.backup_progress() + if not status: + raise RuntimeError("Failed to retrieve backup progress.") perc_search = re.search(r"\s([0-9]*)\s", status["alternativePercentage"]) perc_complete = int( perc_search.group(1) # type: ignore # ignore that re.search can return None @@ -4664,12 +4671,15 @@ def backup_complete(self) -> bool | None: file_size = int(status["size"]) return perc_complete >= 100 and file_size > 0 - def backup_download(self, filename: str = None): + def backup_download(self, filename: str | None = None): """Download backup file from WebDAV (cloud only).""" if not self._is_cloud: self.log.warning("This functionality is not available in Server version") return None - remote_file = self.backup_progress()["fileName"] + progress = self.backup_progress() + if not progress: + raise RuntimeError("Unable to retrieve backup progress.") + remote_file = progress["fileName"] local_file = filename or remote_file url = self.server_url + "/webdav/backupmanager/" + remote_file try: @@ -4904,16 +4914,16 @@ def get_issue_type_scheme_associations(self, id: str) -> list[Project]: def create_project( self, key: str, - name: str = None, - assignee: str = None, + name: str | None = None, + assignee: str | None = None, ptype: str = "software", - template_name: str = None, - avatarId: int = None, - issueSecurityScheme: int = None, - permissionScheme: int = None, - projectCategory: int = None, + template_name: str | None = None, + avatarId: int | None = None, + issueSecurityScheme: int | None = None, + permissionScheme: int | None = None, + projectCategory: int | None = None, notificationScheme: int = 10000, - categoryId: int = None, + categoryId: int | None = None, url: str = "", ): """Create a project with the specified parameters. @@ -4956,6 +4966,8 @@ def create_project( break if permissionScheme is None and ps_list: permissionScheme = ps_list[0]["id"] + if permissionScheme is None: + raise RuntimeError("Unable to identify valid permissionScheme") if issueSecurityScheme is None: ps_list = self.issuesecurityschemes() @@ -4965,6 +4977,8 @@ def create_project( break if issueSecurityScheme is None and ps_list: issueSecurityScheme = ps_list[0]["id"] + if issueSecurityScheme is None: + raise RuntimeError("Unable to identify valid issueSecurityScheme") # If categoryId provided instead of projectCategory, attribute the categoryId value # to the projectCategory variable @@ -5080,8 +5094,8 @@ def add_user( username: str, email: str, directoryId: int = 1, - password: str = None, - fullname: str = None, + password: str | None = None, + fullname: str | None = None, notify: bool = False, active: bool = True, ignore_existing: bool = False, @@ -5217,8 +5231,8 @@ def boards( self, startAt: int = 0, maxResults: int = 50, - type: str = None, - name: str = None, + type: str | None = None, + name: str | None = None, projectKeyOrID=None, ) -> ResultList[Board]: """Get a list of board resources. @@ -5258,7 +5272,7 @@ def sprints( extended: bool | None = None, startAt: int = 0, maxResults: int = 50, - state: str = None, + state: str | None = None, ) -> ResultList[Sprint]: """Get a list of sprint Resources. @@ -5291,7 +5305,7 @@ def sprints( ) def sprints_by_name( - self, id: str | int, extended: bool = False, state: str = None + self, id: str | int, extended: bool = False, state: str | None = None ) -> dict[str, dict[str, Any]]: """Get a dictionary of sprint Resources where the name of the sprint is the key. @@ -5425,7 +5439,7 @@ def create_board( self, name: str, filter_id: str, - project_ids: str = None, + project_ids: str | None = None, preset: str = "scrum", location_type: Literal["user", "project"] = "user", location_id: str | None = None, @@ -5529,7 +5543,10 @@ def add_issues_to_sprint(self, sprint_id: int, issue_keys: list[str]) -> Respons return self._session.post(url, data=json.dumps(payload)) def add_issues_to_epic( - self, epic_id: str, issue_keys: str | list[str], ignore_epics: bool = None + self, + epic_id: str, + issue_keys: str | list[str], + ignore_epics: bool | None = None, ) -> Response: """Add the issues in ``issue_keys`` to the ``epic_id``. diff --git a/jira/config.py b/jira/config.py index 8216c3119..2c6974297 100644 --- a/jira/config.py +++ b/jira/config.py @@ -118,8 +118,7 @@ def findfile(path): verify = config.get(profile, "verify") else: raise OSError( - "%s was not able to locate the config.ini file in current directory, user home directory or PYTHONPATH." - % __name__ + f"{__name__} was not able to locate the config.ini file in current directory, user home directory or PYTHONPATH." ) options = JIRA.DEFAULT_OPTIONS diff --git a/jira/exceptions.py b/jira/exceptions.py index 0047133e5..ff9a0a138 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -12,11 +12,11 @@ class JIRAError(Exception): def __init__( self, - text: str = None, - status_code: int = None, - url: str = None, - request: Response = None, - response: Response = None, + text: str | None = None, + status_code: int | None = None, + url: str | None = None, + request: Response | None = None, + response: Response | None = None, **kwargs, ): """Creates a JIRAError. diff --git a/jira/resources.py b/jira/resources.py index 5922d5087..523a54324 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -295,7 +295,7 @@ def update( self, fields: dict[str, Any] | None = None, async_: bool | None = None, - jira: JIRA = None, + jira: JIRA | None = None, notify: bool = True, **kwargs: Any, ): @@ -335,8 +335,9 @@ def update( and "reporter" not in data["fields"] ): logging.warning( - "autofix: setting reporter to '%s' and retrying the update." - % self._options["autofix"] + "autofix: setting reporter to '{}' and retrying the update.".format( + self._options["autofix"] + ) ) data["fields"]["reporter"] = {"name": self._options["autofix"]} @@ -385,8 +386,7 @@ def update( if user and jira: logging.warning( - "Trying to add missing orphan user '%s' in order to complete the previous failed operation." - % user + f"Trying to add missing orphan user '{user}' in order to complete the previous failed operation." ) jira.add_user(user, "noreply@example.com", 10100, active=False) # if 'assignee' not in data['fields']: @@ -484,7 +484,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "attachment/{0}", options, session) if raw: @@ -509,7 +509,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "component/{0}", options, session) if raw: @@ -536,7 +536,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "customFieldOption/{0}", options, session) if raw: @@ -551,7 +551,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "dashboard/{0}", options, session) if raw: @@ -567,7 +567,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "dashboard/{0}/items/{1}/properties", options, session) if raw: @@ -582,7 +582,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__( self, "dashboard/{0}/items/{1}/properties/{2}", options, session @@ -642,7 +642,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "dashboard/{0}/gadget/{1}", options, session) if raw: @@ -718,7 +718,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "field/{0}", options, session) if raw: @@ -733,7 +733,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "filter/{0}", options, session) if raw: @@ -781,7 +781,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}", options, session) @@ -794,10 +794,10 @@ def __init__( def update( # type: ignore[override] # incompatible supertype ignored self, - fields: dict[str, Any] = None, - update: dict[str, Any] = None, - async_: bool = None, - jira: JIRA = None, + fields: dict[str, Any] | None = None, + update: dict[str, Any] | None = None, + async_: bool | None = None, + jira: JIRA | None = None, notify: bool = True, **fieldargs, ): @@ -906,7 +906,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/comment/{1}", options, session) if raw: @@ -920,7 +920,7 @@ def update( # type: ignore[override] self, fields: dict[str, Any] | None = None, async_: bool | None = None, - jira: JIRA = None, + jira: JIRA | None = None, body: str = "", visibility: dict[str, str] | None = None, is_internal: bool = False, @@ -962,14 +962,20 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/remotelink/{1}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) - def update(self, object, globalId=None, application=None, relationship=None): + def update( # type: ignore[override] + self, + object: dict[str, Any] | None, + globalId=None, + application=None, + relationship=None, + ): """Update a RemoteLink. 'object' is required. For definitions of the allowable fields for 'object' and the keyword arguments 'globalId', 'application' and 'relationship', @@ -989,7 +995,8 @@ def update(self, object, globalId=None, application=None, relationship=None): if relationship is not None: data["relationship"] = relationship - super().update(**data) + # https://github.com/pycontribs/jira/issues/1881 + super().update(**data) # type: ignore[arg-type] class Votes(Resource): @@ -999,7 +1006,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/votes", options, session) if raw: @@ -1084,14 +1091,14 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/watchers", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) - def delete(self, username): + def delete(self, username): # type: ignore[override] """Remove the specified user from the watchers list.""" super().delete(params={"username": username}) @@ -1101,7 +1108,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) self.remainingEstimate = None @@ -1117,7 +1124,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) if raw: @@ -1154,7 +1161,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/properties/{1}", options, session) if raw: @@ -1178,7 +1185,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issueLink/{0}", options, session) if raw: @@ -1193,7 +1200,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issueLinkType/{0}", options, session) if raw: @@ -1208,7 +1215,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issuetype/{0}", options, session) if raw: @@ -1223,7 +1230,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "priority/{0}", options, session) if raw: @@ -1238,7 +1245,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "project/{0}", options, session) if raw: @@ -1253,7 +1260,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "project/{0}/role/{1}", options, session) if raw: @@ -1262,8 +1269,8 @@ def __init__( def update( # type: ignore[override] self, - users: str | list | tuple = None, - groups: str | list | tuple = None, + users: str | list | tuple | None = None, + groups: str | list | tuple | None = None, ): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. @@ -1288,8 +1295,8 @@ def update( # type: ignore[override] def add_user( self, - users: str | list | tuple = None, - groups: str | list | tuple = None, + users: str | list | tuple | None = None, + groups: str | list | tuple | None = None, ): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. @@ -1313,7 +1320,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "resolution/{0}", options, session) if raw: @@ -1328,7 +1335,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "securitylevel/{0}", options, session) if raw: @@ -1343,7 +1350,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "status/{0}", options, session) if raw: @@ -1358,7 +1365,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "statuscategory/{0}", options, session) if raw: @@ -1373,7 +1380,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, *, _query_param: str = "username", ): @@ -1394,7 +1401,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "group?groupname={0}", options, session) if raw: @@ -1409,7 +1416,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "version/{0}", options, session) if raw: @@ -1433,7 +1440,8 @@ def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): return super().delete(params) - def update(self, **kwargs): + # TODO: https://github.com/pycontribs/jira/issues/1881 + def update(self, **kwargs): # type: ignore[override] """Update this project version from the server. It is prior used to archive versions. Refer to Atlassian REST API `documentation`_. @@ -1478,7 +1486,7 @@ def __init__( path: str, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): self.self = None @@ -1495,7 +1503,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): AgileResource.__init__(self, "sprint/{0}", options, session, raw) @@ -1507,7 +1515,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): AgileResource.__init__(self, "board/{id}", options, session, raw) @@ -1522,7 +1530,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__( self, "customer", options, session, "{server}/rest/servicedeskapi/{path}" @@ -1539,7 +1547,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__( self, @@ -1560,7 +1568,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__( self, @@ -1687,7 +1695,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "unknown{0}", options, session) if raw: diff --git a/pyproject.toml b/pyproject.toml index 133e1bf3d..8dc146aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,16 @@ test = [ [project.scripts] jirashell = "jira.jirashell:main" +[tool.codespell] +skip = [ + "./build", + "./docs/build", + "./node_modules", + "AUTHORS", + "ChangeLog" +] +ignore-words = ".config/dictionary.txt" + [tool.files] packages = """ jira""" diff --git a/tests/resources/test_epic.py b/tests/resources/test_epic.py index 42885cee2..e38e42c07 100644 --- a/tests/resources/test_epic.py +++ b/tests/resources/test_epic.py @@ -59,5 +59,5 @@ def test_add_issues_to_epic(self, name: str, input_type): with self.make_epic() as new_epic: self.jira.add_issues_to_epic( new_epic.id, - ",".join(issue_list) if input_type == str else issue_list, + ",".join(issue_list) if input_type == str else issue_list, # noqa: E721 ) diff --git a/tests/resources/test_issue.py b/tests/resources/test_issue.py index bd30c95a3..a496edb2e 100644 --- a/tests/resources/test_issue.py +++ b/tests/resources/test_issue.py @@ -21,18 +21,18 @@ def test_issue(self): self.assertEqual(issue.fields.summary, f"issue 1 from {self.project_b}") def test_issue_search_finds_issue(self): - issues = self.jira.search_issues("key=%s" % self.issue_1) + issues = self.jira.search_issues(f"key={self.issue_1}") self.assertEqual(self.issue_1, issues[0].key) def test_issue_search_return_type(self): - issues = self.jira.search_issues("key=%s" % self.issue_1) + issues = self.jira.search_issues(f"key={self.issue_1}") self.assertIsInstance(issues, list) - issues = self.jira.search_issues("key=%s" % self.issue_1, json_result=True) + issues = self.jira.search_issues(f"key={self.issue_1}", json_result=True) self.assertIsInstance(issues, dict) def test_issue_search_only_includes_provided_fields(self): issues = self.jira.search_issues( - "key=%s" % self.issue_1, fields="comment,assignee" + f"key={self.issue_1}", fields="comment,assignee" ) self.assertTrue(hasattr(issues[0].fields, "comment")) self.assertTrue(hasattr(issues[0].fields, "assignee")) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 09f6c59e2..191c6614b 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -21,7 +21,7 @@ class ExceptionsTests(unittest.TestCase): class MockResponse(Response): def __init__( self, - headers: dict = None, + headers: dict | None = None, text: str = "", status_code: int = DUMMY_STATUS_CODE, url: str = DUMMY_URL, @@ -43,7 +43,7 @@ def text(self, new_text): class MalformedMockResponse: def __init__( self, - headers: dict = None, + headers: dict | None = None, text: str = "", status_code: int = DUMMY_STATUS_CODE, url: str = DUMMY_URL, diff --git a/tests/tests.py b/tests/tests.py index f040150fa..bbc16c722 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -482,7 +482,7 @@ def _calculate_calls_for_fetch_pages( total: int, max_results: int, batch_size: int | None, - default: int | None = 10, + default: int = 10, ): """Returns expected query parameters for specified search-issues arguments.""" if not max_results: From 39d89ef2d0f3d6d50d1177b80aa47f3fa1125a2e Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Sat, 24 Aug 2024 14:00:02 +0100 Subject: [PATCH 03/10] Adopt tox workflow using coactions/dynamic-matrix (#1883) --- .config/dictionary.txt | 1 + .github/CODEOWNERS | 2 + .github/workflows/jira_ci.yml | 15 ++- .github/workflows/tox.yml | 179 ++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 3 +- README.rst | 5 - docs/conf.py | 3 +- jira/client.py | 8 +- pyproject.toml | 21 +++- tox.ini | 5 +- 10 files changed, 219 insertions(+), 23 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/tox.yml diff --git a/.config/dictionary.txt b/.config/dictionary.txt index bf52b4c93..306621c18 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -1 +1,2 @@ +CAs assertIn diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..838a2518f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @pycontribs/jira +/.github/ @ssbarnea diff --git a/.github/workflows/jira_ci.yml b/.github/workflows/jira_ci.yml index a793b12d7..dac7bc563 100644 --- a/.github/workflows/jira_ci.yml +++ b/.github/workflows/jira_ci.yml @@ -1,17 +1,16 @@ name: ci +# runs only after tox workflow finished successfully on: - # Trigger the workflow on push or pull request, - # but only for the main branch - push: - branches: - - main - pull_request: - branches: - - main + workflow_run: + workflows: [tox] + branches: [main] + types: + - completed jobs: server: + if: ${{ github.event.workflow_run.conclusion == 'success' }} uses: pycontribs/jira/.github/workflows/jira_server_ci.yml@main cloud: diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 000000000..f6c953481 --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,179 @@ +--- +name: tox +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 # tox, pytest + PY_COLORS: 1 + +jobs: + prepare: + name: prepare + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.generate_matrix.outputs.matrix }} + steps: + - name: Determine matrix + id: generate_matrix + uses: coactions/dynamic-matrix@v3 + with: + min_python: "3.9" + max_python: "3.12" + default_python: "3.9" + other_names: | + lint + docs + pkg + # ^ arm64 runner is using py311 for matching python version used in AAP 2.5 + platforms: linux,macos,linux-arm64:ubuntu-24.04-arm64-2core + skip_explode: "1" + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os || 'ubuntu-24.04' }} + needs: + - prepare + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} + steps: + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed by setuptools-scm + submodules: true + + - name: Set pre-commit cache + uses: actions/cache@v4 + if: ${{ contains(matrix.name, 'lint') }} + with: + path: | + ~/.cache/pre-commit + key: pre-commit-${{ matrix.name }}-${{ hashFiles('.pre-commit-config.yaml') }} + + - name: Set up Python ${{ matrix.python_version || '3.10' }} + uses: actions/setup-python@v5 + with: + cache: pip + python-version: ${{ matrix.python_version || '3.10' }} + cache-dependency-path: "*requirements*.txt" + + - name: Install tox + run: | + python3 -m pip install --upgrade pip wheel tox + + - run: ${{ matrix.command }} + + - run: ${{ matrix.command2 }} + if: ${{ matrix.command2 }} + + - run: ${{ matrix.command3 }} + if: ${{ matrix.command3 }} + + - run: ${{ matrix.command4 }} + if: ${{ matrix.command4 }} + + - run: ${{ matrix.command5 }} + if: ${{ matrix.command5 }} + + - name: Archive logs + uses: actions/upload-artifact@v4 + with: + name: logs-${{ matrix.name }}.zip + if-no-files-found: error + path: | + .tox/**/log/ + .tox/**/coverage.xml + + - name: Report failure if git reports dirty status + run: | + if [[ -n $(git status -s) ]]; then + # shellcheck disable=SC2016 + echo -n '::error file=git-status::' + printf '### Failed as git reported modified and/or untracked files\n```\n%s\n```\n' "$(git status -s)" | tee -a "$GITHUB_STEP_SUMMARY" + exit 99 + fi + # https://github.com/actions/toolkit/issues/193 + check: + if: always() + environment: check + permissions: + id-token: write + checks: read + + needs: + - build + + runs-on: ubuntu-24.04 + + steps: + # checkout needed for codecov action which needs codecov.yml file + - uses: actions/checkout@v4 + + - name: Set up Python # likely needed for coverage + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip3 install 'coverage>=7.5.1' + + - name: Merge logs into a single archive + uses: actions/upload-artifact/merge@v4 + with: + name: logs.zip + pattern: logs-*.zip + # artifacts like py312.zip and py312-macos do have overlapping files + separate-directories: true + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: logs.zip + path: . + + - name: Check for expected number of coverage reports + run: .github/check-coverage.sh + + # Single uploads inside check job for codecov to allow use to retry + # it when it fails without running tests again. Fails often enough! + - name: Upload junit xml reports + # PRs from forks might not have access to the secret + if: env.CODECOV_TOKEN + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN || env.CODECOV_TOKEN }} + uses: codecov/test-results-action@v1 + with: + name: ${{ matrix.name }} + files: "*/tests/output/junit/*.xml" + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage data + uses: codecov/codecov-action@v4 + with: + name: ${{ matrix.name }} + # verbose: true # optional (default = false) + fail_ci_if_error: true + use_oidc: true # cspell:ignore oidc + files: "*/tests/output/reports/coverage.xml" + + # - name: Check codecov.io status + # if: github.event_name == 'pull_request' + # uses: coactions/codecov-status@main + + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + + - name: Delete Merged Artifacts + uses: actions/upload-artifact/merge@v4 + with: + delete-merged: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96be8bad6..0ff461314 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,8 @@ repos: types: [text] args: [] require_serial: false - additional_dependencies: [] + additional_dependencies: + - tomli; python_version<'3.11' - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.6.2" hooks: diff --git a/README.rst b/README.rst index 413227c52..24499a673 100644 --- a/README.rst +++ b/README.rst @@ -11,11 +11,6 @@ Jira Python Library .. image:: https://img.shields.io/github/issues/pycontribs/jira.svg :target: https://github.com/pycontribs/jira/issues -.. image:: https://img.shields.io/badge/irc-%23pycontribs-blue - :target: irc:///#pycontribs - ------------- - .. image:: https://readthedocs.org/projects/jira/badge/?version=main :target: https://jira.readthedocs.io/ diff --git a/docs/conf.py b/docs/conf.py index 6ba493e84..c04c59b96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -81,8 +81,7 @@ # The encoding of source files. # source_encoding = 'utf-8-sig' -# The master toctree document. -master_doc = "index" +root_doc = "index" # General information about the project. project = py_pkg.__name__ diff --git a/jira/client.py b/jira/client.py index aefae7cd1..980dfa230 100644 --- a/jira/client.py +++ b/jira/client.py @@ -3279,7 +3279,7 @@ def create_temp_project_avatar( This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This dict should be saved and passed to :py:meth:`confirm_project_avatar` to finish the avatar creation process. - If you want to cut out the middleman and confirm the avatar with Jira's default cropping, + If you want to confirm the avatar with Jira's default cropping, pass the 'auto_confirm' argument with a truthy value and :py:meth:`confirm_project_avatar` will be called for you before this method returns. Args: @@ -3825,7 +3825,7 @@ def create_temp_user_avatar( This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This dict should be saved and passed to :py:meth:`confirm_user_avatar` to finish the avatar creation process. - If you want to cut out the middleman and confirm the avatar with Jira's default cropping, pass the ``auto_confirm`` argument with a truthy value and + If you want to confirm the avatar with Jira's default cropping, pass the ``auto_confirm`` argument with a truthy value and :py:meth:`confirm_user_avatar` will be called for you before this method returns. Args: @@ -4234,7 +4234,7 @@ def _add_client_cert_to_session(self): If configured through the constructor. - https://docs.python-requests.org/en/master/user/advanced/#client-side-certificates + https://docs.python-requests.org/en/latest/user/advanced/#client-side-certificates - str: a single file (containing the private key and the certificate) - Tuple[str,str] a tuple of both files’ paths """ @@ -4246,7 +4246,7 @@ def _add_ssl_cert_verif_strategy_to_session(self): If configured through the constructor. - https://docs.python-requests.org/en/master/user/advanced/#ssl-cert-verification + https://docs.python-requests.org/en/latest/user/advanced/#ssl-cert-verification - str: Path to a `CA_BUNDLE` file or directory with certificates of trusted CAs. - bool: True/False """ diff --git a/pyproject.toml b/pyproject.toml index 8dc146aad..3de8c41dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,14 +93,31 @@ test = [ jirashell = "jira.jirashell:main" [tool.codespell] +check-filenames = true +check-hidden = true +quiet-level = 0 +write-changes = true +enable-colors = true skip = [ + "./.eggs", + "./.git", + "./.mypy_cache", + "./.tox", "./build", "./docs/build", "./node_modules", + "./pip-wheel-metadata", + "./tests/icon.png", + ".DS_Store", + ".ruff_cache", "AUTHORS", - "ChangeLog" + "ChangeLog", + "__pycache__", + "coverage.xml", + "dist", ] -ignore-words = ".config/dictionary.txt" +builtin = ["clear", "rare", "usage", "names", "code"] +ignore-words = [".config/dictionary.txt"] [tool.files] packages = """ diff --git a/tox.ini b/tox.ini index b398da265..8df5886e0 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,9 @@ requires = # tox-extra # tox-pyenv envlist = + lint + pkg + docs py311 py310 py39 @@ -99,7 +102,7 @@ commands = 'import pathlib; '\ 'docs_dir = pathlib.Path(r"{toxworkdir}") / "docs_out"; index_file = docs_dir / "index.html"; print(f"\nDocumentation available under `file://\{index_file\}`\n\nTo serve docs, use `python3 -m http.server --directory \{docs_dir\} 0`\n")' -[testenv:packaging] +[testenv:pkg] basepython = python3 description = Build package, verify metadata, install package and assert behavior when ansible is missing. From 91798f4df427c99969a7de442d069e179d97cc8b Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Sat, 24 Aug 2024 14:13:13 +0100 Subject: [PATCH 04/10] Fix docs jobs (#1884) --- constraints.txt | 2 +- docs/api.rst | 4 ++++ docs/conf.py | 3 ++- jira/client.py | 10 ++-------- pyproject.toml | 2 +- tox.ini | 4 +++- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/constraints.txt b/constraints.txt index 9d59e9a7e..db5b572fb 100644 --- a/constraints.txt +++ b/constraints.txt @@ -35,7 +35,7 @@ decorator==5.1.1 # via ipython defusedxml==0.7.1 # via jira (setup.cfg) -docutils==0.20.1 +docutils==0.21.2 # via # jira (setup.cfg) # sphinx diff --git a/docs/api.rst b/docs/api.rst index a78c0feb1..454190fd5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -76,6 +76,10 @@ jira.resources module :undoc-members: :show-inheritance: +.. autoclass:: jira.resources.Field + :members: + :undoc-members: + :show-inheritance: jira.utils module ----------------- diff --git a/docs/conf.py b/docs/conf.py index c04c59b96..df10616e4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -98,7 +98,8 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -# language = None +language = "en" +locale_dirs: list[str] = [] # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/jira/client.py b/jira/client.py index 980dfa230..b090db913 100644 --- a/jira/client.py +++ b/jira/client.py @@ -2811,14 +2811,8 @@ def add_worklog( started (Optional[datetime.datetime]): Moment when the work is logged, if not specified will default to now user (Optional[str]): the user ID or name to use for this worklog visibility (Optional[Dict[str,Any]]): Details about any restrictions in the visibility of the worklog. - Optional when creating or updating a worklog. :: - ```js - { - "type": "group", # "group" or "role" - "value": "", - "identifier": "" # OPTIONAL - } - ``` + Example of visibility options when creating or updating a worklog. + ``{ "type": "group", "value": "", "identifier": ""}`` Returns: Worklog diff --git a/pyproject.toml b/pyproject.toml index 3de8c41dd..2260e162d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ opt = [ ] async = ["requests-futures>=0.9.7"] test = [ - "docutils>=0.12", + "docutils>=0.21.2", "flaky", "MarkupSafe>=0.23", "oauthlib", diff --git a/tox.ini b/tox.ini index 8df5886e0..8e7441699 100644 --- a/tox.ini +++ b/tox.ini @@ -92,7 +92,9 @@ setenv = PYTHONHTTPSVERIFY=0 commands = sphinx-build \ - -a -n -v -W --keep-going \ + --verbose \ + --write-all \ + --nitpicky --fail-on-warning \ -b html --color \ -d "{toxworkdir}/docs_doctree" \ docs/ "{toxworkdir}/docs_out" From 5bf171661083a407e9f298c7bf6ed7f0256354ca Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Sat, 24 Aug 2024 20:36:20 +0100 Subject: [PATCH 05/10] Remove support for py38 and enable py312 testing (#1886) --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/jira_cloud_ci.yml | 4 +- .github/workflows/jira_server_ci.yml | 2 +- .github/workflows/release.yml | 4 +- .github/workflows/tox.yml | 28 +++- bindep.txt | 2 + constraints.txt | 214 +++++++++++++------------- docs/conf.py | 2 +- docs/contributing.rst | 4 +- docs/installation.rst | 2 +- jira/client.py | 28 ++-- jira/resources.py | 94 +++++------ pyproject.toml | 12 +- tests/resources/test_board.py | 2 +- tests/resources/test_epic.py | 2 +- tests/resources/test_sprint.py | 2 +- tests/test_exceptions.py | 16 +- tox.ini | 12 +- 18 files changed, 224 insertions(+), 208 deletions(-) create mode 100644 bindep.txt diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b3b7cfee5..44708ffd7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -50,7 +50,7 @@ body: attributes: label: Python Interpreter version description: The version(s) of Python used. - placeholder: "3.8" + placeholder: "3.9" validations: required: true - type: checkboxes diff --git a/.github/workflows/jira_cloud_ci.yml b/.github/workflows/jira_cloud_ci.yml index 2ecc5b47f..377dd06cc 100644 --- a/.github/workflows/jira_cloud_ci.yml +++ b/.github/workflows/jira_cloud_ci.yml @@ -23,7 +23,7 @@ jobs: os: [ubuntu-latest] # We only test a single version to prevent concurrent # running of tests influencing one another - python-version: ["3.8"] + python-version: ["3.9"] steps: - uses: actions/checkout@v4 @@ -41,7 +41,7 @@ jobs: python -m pip install --upgrade tox tox-gh-actions - name: Test with tox - run: tox -e py38 -- -m allow_on_cloud + run: tox -e py39 -- -m allow_on_cloud env: CI_JIRA_TYPE: CLOUD CI_JIRA_CLOUD_ADMIN: ${{ secrets.CLOUD_ADMIN }} diff --git a/.github/workflows/jira_server_ci.yml b/.github/workflows/jira_server_ci.yml index 6b1c46644..9b5ca43b2 100644 --- a/.github/workflows/jira_server_ci.yml +++ b/.github/workflows/jira_server_ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] jira-version: [8.17.1] steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 473b73a39..2e7fb6c47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,10 +17,10 @@ jobs: TOX_PARALLEL_NO_SPINNER: 1 steps: - - name: Switch to using Python 3.8 by default + - name: Switch to using Python 3.9 by default uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install build dependencies run: python3 -m pip install --user tox diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index f6c953481..c89fc012f 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -32,8 +32,14 @@ jobs: lint docs pkg - # ^ arm64 runner is using py311 for matching python version used in AAP 2.5 - platforms: linux,macos,linux-arm64:ubuntu-24.04-arm64-2core + py39:tox -e py39 --notest + py310:tox -e py310 --notest + py311:tox -e py311 --notest + py312:tox -e py312 --notest + py39-macos:tox -e py312 --notest + py312-macos:tox -e py312 --notest + # ^ macos is also used to validate arm64 building + platforms: linux,macos skip_explode: "1" build: name: ${{ matrix.name }} @@ -45,6 +51,13 @@ jobs: matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} steps: + - name: Install package dependencies (ubuntu) + if: ${{ contains(matrix.os, 'ubuntu') }} + run: | + sudo apt remove -y docker-compose + sudo apt-get update -y + sudo apt-get --assume-yes --no-install-recommends install -y apt-transport-https curl libkrb5-dev + - uses: actions/checkout@v4 with: fetch-depth: 0 # needed by setuptools-scm @@ -138,8 +151,13 @@ jobs: name: logs.zip path: . - - name: Check for expected number of coverage reports - run: .github/check-coverage.sh + - name: Check for expected number of coverage.xml reports + run: | + JOBS_PRODUCING_COVERAGE=0 + if [ "$(find . -name coverage.xml | wc -l | bc)" -ne "${JOBS_PRODUCING_COVERAGE}" ]; then + echo "::error::Number of coverage.xml files was not the expected one (${JOBS_PRODUCING_COVERAGE}): $(find . -name coverage.xml |xargs echo)" + exit 1 + fi # Single uploads inside check job for codecov to allow use to retry # it when it fails without running tests again. Fails often enough! @@ -160,7 +178,7 @@ jobs: with: name: ${{ matrix.name }} # verbose: true # optional (default = false) - fail_ci_if_error: true + fail_ci_if_error: false use_oidc: true # cspell:ignore oidc files: "*/tests/output/reports/coverage.xml" diff --git a/bindep.txt b/bindep.txt new file mode 100644 index 000000000..e4246ba0b --- /dev/null +++ b/bindep.txt @@ -0,0 +1,2 @@ +# gssapi pypi wheel build needs: +libkrb5-dev [platform:dpkg] diff --git a/constraints.txt b/constraints.txt index db5b572fb..589254686 100644 --- a/constraints.txt +++ b/constraints.txt @@ -1,134 +1,137 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # -# pip-compile --extra=async --extra=cli --extra=docs --extra=opt --extra=test --output-file=constraints.txt --strip-extras setup.cfg +# pip-compile --extra=async --extra=cli --extra=docs --extra=opt --extra=test --output-file=constraints.txt --strip-extras # -alabaster==0.7.13 +alabaster==0.7.16 # via sphinx asttokens==2.4.1 # via stack-data -babel==2.14.0 +babel==2.16.0 # via sphinx -backcall==0.2.0 - # via ipython +backports-tarfile==1.2.0 + # via jaraco-context beautifulsoup4==4.12.3 # via furo -certifi==2024.2.2 +certifi==2024.7.4 # via requests -cffi==1.16.0 +cffi==1.17.0 # via cryptography charset-normalizer==3.3.2 # via requests -colorama==0.4.6 - # via - # ipython - # pytest - # sphinx -coverage==7.4.4 +coverage==7.6.1 # via pytest-cov -cryptography==42.0.5 +cryptography==43.0.0 # via # pyspnego # requests-kerberos decorator==5.1.1 - # via ipython + # via + # gssapi + # ipython defusedxml==0.7.1 - # via jira (setup.cfg) + # via jira (pyproject.toml) docutils==0.21.2 # via - # jira (setup.cfg) + # jira (pyproject.toml) # sphinx -exceptiongroup==1.2.0 - # via pytest -execnet==2.0.2 +exceptiongroup==1.2.2 + # via + # ipython + # pytest +execnet==2.1.1 # via # pytest-cache # pytest-xdist executing==2.0.1 # via stack-data filemagic==1.6 - # via jira (setup.cfg) + # via jira (pyproject.toml) flaky==3.8.1 - # via jira (setup.cfg) -furo==2024.1.29 - # via jira (setup.cfg) -idna==3.6 + # via jira (pyproject.toml) +furo==2024.8.6 + # via jira (pyproject.toml) +gssapi==1.8.3 + # via pyspnego +idna==3.8 # via requests imagesize==1.4.1 # via sphinx -importlib-metadata==7.1.0 +importlib-metadata==8.4.0 # via # keyring # sphinx -importlib-resources==6.4.0 - # via keyring iniconfig==2.0.0 # via pytest -ipython==8.12.3 - # via jira (setup.cfg) -jaraco-classes==3.3.1 +ipython==8.18.1 + # via jira (pyproject.toml) +jaraco-classes==3.4.0 # via keyring -jaraco-context==4.3.0 +jaraco-context==6.0.1 # via keyring -jaraco-functools==4.0.0 +jaraco-functools==4.0.2 # via keyring jedi==0.19.1 # via ipython -jinja2==3.1.3 +jinja2==3.1.4 # via sphinx -keyring==25.0.0 - # via jira (setup.cfg) +keyring==25.3.0 + # via jira (pyproject.toml) +krb5==0.6.0 + # via pyspnego markupsafe==2.1.5 # via # jinja2 - # jira (setup.cfg) -matplotlib-inline==0.1.6 + # jira (pyproject.toml) +matplotlib-inline==0.1.7 # via ipython -more-itertools==10.2.0 +more-itertools==10.4.0 # via # jaraco-classes # jaraco-functools oauthlib==3.2.2 # via - # jira (setup.cfg) + # jira (pyproject.toml) # requests-oauthlib -packaging==24.0 +packaging==24.1 # via - # jira (setup.cfg) + # jira (pyproject.toml) # pytest # pytest-sugar # sphinx parameterized==0.9.0 - # via jira (setup.cfg) -parso==0.8.3 + # via jira (pyproject.toml) +parso==0.8.4 # via jedi -pickleshare==0.7.5 +pexpect==4.9.0 # via ipython -pillow==10.2.0 - # via jira (setup.cfg) -pluggy==1.4.0 +pillow==10.4.0 + # via jira (pyproject.toml) +pluggy==1.5.0 # via pytest -prompt-toolkit==3.0.43 +prompt-toolkit==3.0.47 # via ipython -pure-eval==0.2.2 +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.3 # via stack-data -pycparser==2.21 +pycparser==2.22 # via cffi -pygments==2.17.2 +pygments==2.18.0 # via # furo # ipython # sphinx -pyjwt==2.8.0 +pyjwt==2.9.0 # via - # jira (setup.cfg) + # jira (pyproject.toml) # requests-jwt -pyspnego==0.10.2 +pyspnego==0.11.1 # via requests-kerberos -pytest==8.1.1 +pytest==8.3.2 # via - # jira (setup.cfg) + # jira (pyproject.toml) # pytest-cache # pytest-cov # pytest-instafail @@ -136,26 +139,22 @@ pytest==8.1.1 # pytest-timeout # pytest-xdist pytest-cache==1.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) pytest-cov==5.0.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) pytest-instafail==0.5.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) pytest-sugar==1.0.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) pytest-timeout==2.3.1 - # via jira (setup.cfg) -pytest-xdist==3.5.0 - # via jira (setup.cfg) -pytz==2024.1 - # via babel -pywin32-ctypes==0.2.2 - # via keyring -pyyaml==6.0.1 - # via jira (setup.cfg) -requests==2.31.0 - # via - # jira (setup.cfg) + # via jira (pyproject.toml) +pytest-xdist==3.6.1 + # via jira (pyproject.toml) +pyyaml==6.0.2 + # via jira (pyproject.toml) +requests==2.32.3 + # via + # jira (pyproject.toml) # requests-futures # requests-jwt # requests-kerberos @@ -165,78 +164,73 @@ requests==2.31.0 # requires-io # sphinx requests-futures==1.0.1 - # via jira (setup.cfg) + # via jira (pyproject.toml) requests-jwt==0.6.0 - # via jira (setup.cfg) -requests-kerberos==0.14.0 - # via jira (setup.cfg) -requests-mock==1.11.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) +requests-kerberos==0.15.0 + # via jira (pyproject.toml) +requests-mock==1.12.1 + # via jira (pyproject.toml) requests-oauthlib==2.0.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) requests-toolbelt==1.0.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) requires-io==0.2.6 - # via jira (setup.cfg) + # via jira (pyproject.toml) six==1.16.0 - # via - # asttokens - # requests-mock + # via asttokens snowballstemmer==2.2.0 # via sphinx -soupsieve==2.5 +soupsieve==2.6 # via beautifulsoup4 -sphinx==7.1.2 +sphinx==7.4.7 # via # furo - # jira (setup.cfg) + # jira (pyproject.toml) # sphinx-basic-ng # sphinx-copybutton sphinx-basic-ng==1.0.0b2 # via furo sphinx-copybutton==0.5.2 - # via jira (setup.cfg) -sphinxcontrib-applehelp==1.0.4 + # via jira (pyproject.toml) +sphinxcontrib-applehelp==2.0.0 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==2.0.0 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==2.0.0 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sspilib==0.1.0 - # via pyspnego stack-data==0.6.3 # via ipython -tenacity==8.2.3 - # via jira (setup.cfg) +tenacity==9.0.0 + # via jira (pyproject.toml) termcolor==2.4.0 # via pytest-sugar tomli==2.0.1 # via # coverage # pytest -traitlets==5.14.2 + # sphinx +traitlets==5.14.3 # via # ipython # matplotlib-inline -typing-extensions==4.10.0 +typing-extensions==4.12.2 # via # ipython - # jira (setup.cfg) -urllib3==2.2.1 + # jira (pyproject.toml) +urllib3==2.2.2 # via requests wcwidth==0.2.13 # via prompt-toolkit -wheel==0.43.0 - # via jira (setup.cfg) +wheel==0.44.0 + # via jira (pyproject.toml) yanc==0.3.3 - # via jira (setup.cfg) -zipp==3.18.1 - # via - # importlib-metadata - # importlib-resources + # via jira (pyproject.toml) +zipp==3.20.0 + # via importlib-metadata diff --git a/docs/conf.py b/docs/conf.py index df10616e4..40a859a22 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,7 +34,7 @@ ] intersphinx_mapping = { - "python": ("https://docs.python.org/3.8", None), + "python": ("https://docs.python.org/3.9", None), "requests": ("https://requests.readthedocs.io/en/latest/", None), "requests-oauthlib": ("https://requests-oauthlib.readthedocs.io/en/latest/", None), "ipython": ("https://ipython.readthedocs.io/en/stable/", None), diff --git a/docs/contributing.rst b/docs/contributing.rst index 0f8c39c23..0d6fcf96f 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -75,10 +75,10 @@ Using tox * Run tests - ``tox`` * Run tests for one env only - - ``tox -e py38`` + - ``tox -e py`` * Specify what tests to run with pytest_ - ``tox -e py39 -- tests/resources/test_attachment.py`` - - ``tox -e py38 -- -m allow_on_cloud`` (Run only the cloud tests) + - ``tox -e py310 -- -m allow_on_cloud`` (Run only the cloud tests) * Debug tests with breakpoints by disabling the coverage plugin, with the ``--no-cov`` argument. - Example for VSCode on Windows : diff --git a/docs/installation.rst b/docs/installation.rst index 723abda01..d8df76072 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -34,7 +34,7 @@ Source packages are also available at PyPI: Dependencies ============ -Python >=3.8 is required. +Python >=3.9 is required. - :py:mod:`requests` - `python-requests `_ library handles the HTTP business. Usually, the latest version available at time of release is the minimum version required; at this writing, that version is 1.2.0, but any version >= 1.0.0 should work. - :py:mod:`requests-oauthlib` - Used to implement OAuth. The latest version as of this writing is 1.3.0. diff --git a/jira/client.py b/jira/client.py index b090db913..851b7f5d8 100644 --- a/jira/client.py +++ b/jira/client.py @@ -22,16 +22,14 @@ import urllib import warnings from collections import OrderedDict -from collections.abc import Iterable -from functools import lru_cache, wraps +from collections.abc import Iterable, Iterator +from functools import cache, wraps from io import BufferedReader from numbers import Number from typing import ( Any, Callable, Generic, - Iterator, - List, Literal, SupportsIndex, TypeVar, @@ -1138,7 +1136,7 @@ def prepare( if not js or not isinstance(js, Iterable): raise JIRAError(f"Unable to parse JSON: {js}. Failed to add attachment?") jira_attachment = Attachment( - self._options, self._session, js[0] if isinstance(js, List) else js + self._options, self._session, js[0] if isinstance(js, list) else js ) if jira_attachment.size == 0: raise JIRAError( @@ -4772,7 +4770,7 @@ def _gain_sudo_session(self, options, destination): data=payload, ) - @lru_cache(maxsize=None) + @cache def templates(self) -> dict: url = self.server_url + "/rest/project-templates/latest/templates" @@ -4787,7 +4785,7 @@ def templates(self) -> dict: # pprint(templates.keys()) return templates - @lru_cache(maxsize=None) + @cache def permissionschemes(self): url = self._get_url("permissionscheme") @@ -4796,7 +4794,7 @@ def permissionschemes(self): return data["permissionSchemes"] - @lru_cache(maxsize=None) + @cache def issue_type_schemes(self) -> list[IssueTypeScheme]: """Get all issue type schemes defined (Admin required). @@ -4810,7 +4808,7 @@ def issue_type_schemes(self) -> list[IssueTypeScheme]: return data["schemes"] - @lru_cache(maxsize=None) + @cache def issuesecurityschemes(self): url = self._get_url("issuesecurityschemes") @@ -4819,7 +4817,7 @@ def issuesecurityschemes(self): return data["issueSecuritySchemes"] - @lru_cache(maxsize=None) + @cache def projectcategories(self): url = self._get_url("projectCategory") @@ -4828,7 +4826,7 @@ def projectcategories(self): return data - @lru_cache(maxsize=None) + @cache def avatars(self, entity="project"): url = self._get_url(f"avatar/{entity}/system") @@ -4837,7 +4835,7 @@ def avatars(self, entity="project"): return data["system"] - @lru_cache(maxsize=None) + @cache def notificationschemes(self): # TODO(ssbarnea): implement pagination support url = self._get_url("notificationscheme") @@ -4846,7 +4844,7 @@ def notificationschemes(self): data: dict[str, Any] = json_loads(r) return data["values"] - @lru_cache(maxsize=None) + @cache def screens(self): # TODO(ssbarnea): implement pagination support url = self._get_url("screens") @@ -4855,7 +4853,7 @@ def screens(self): data: dict[str, Any] = json_loads(r) return data["values"] - @lru_cache(maxsize=None) + @cache def workflowscheme(self): # TODO(ssbarnea): implement pagination support url = self._get_url("workflowschemes") @@ -4864,7 +4862,7 @@ def workflowscheme(self): data = json_loads(r) return data # ['values'] - @lru_cache(maxsize=None) + @cache def workflows(self): # TODO(ssbarnea): implement pagination support url = self._get_url("workflow") diff --git a/jira/resources.py b/jira/resources.py index 523a54324..e72b7f456 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -10,7 +10,7 @@ import logging import re import time -from typing import TYPE_CHECKING, Any, Dict, List, Type, cast +from typing import TYPE_CHECKING, Any, cast from requests import Response from requests.structures import CaseInsensitiveDict @@ -489,7 +489,7 @@ def __init__( Resource.__init__(self, "attachment/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def get(self): """Return the file content as a string.""" @@ -514,7 +514,7 @@ def __init__( Resource.__init__(self, "component/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def delete(self, moveIssuesTo: str | None = None): # type: ignore[override] """Delete this component from the server. @@ -541,7 +541,7 @@ def __init__( Resource.__init__(self, "customFieldOption/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Dashboard(Resource): @@ -557,7 +557,7 @@ def __init__( if raw: self._parse_raw(raw) self.gadgets: list[DashboardGadget] = [] - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class DashboardItemPropertyKey(Resource): @@ -572,7 +572,7 @@ def __init__( Resource.__init__(self, "dashboard/{0}/items/{1}/properties", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class DashboardItemProperty(Resource): @@ -589,7 +589,7 @@ def __init__( ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] # incompatible supertype ignored self, dashboard_id: str, item_id: str, value: dict[str, Any] @@ -648,7 +648,7 @@ def __init__( if raw: self._parse_raw(raw) self.item_properties: list[DashboardItemProperty] = [] - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] # incompatible supertype ignored self, @@ -723,7 +723,7 @@ def __init__( Resource.__init__(self, "field/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Filter(Resource): @@ -738,7 +738,7 @@ def __init__( Resource.__init__(self, "filter/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Issue(Resource): @@ -790,7 +790,7 @@ def __init__( self.key: str if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] # incompatible supertype ignored self, @@ -911,7 +911,7 @@ def __init__( Resource.__init__(self, "issue/{0}/comment/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] # The above ignore is added because we've added new parameters and order of @@ -967,7 +967,7 @@ def __init__( Resource.__init__(self, "issue/{0}/remotelink/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] self, @@ -1011,7 +1011,7 @@ def __init__( Resource.__init__(self, "issue/{0}/votes", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class IssueTypeScheme(Resource): @@ -1021,7 +1021,7 @@ def __init__(self, options, session, raw=None): Resource.__init__(self, "issuetypescheme", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class IssueSecurityLevelScheme(Resource): @@ -1033,7 +1033,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class NotificationScheme(Resource): @@ -1045,7 +1045,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class PermissionScheme(Resource): @@ -1057,7 +1057,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class PriorityScheme(Resource): @@ -1069,7 +1069,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class WorkflowScheme(Resource): @@ -1081,7 +1081,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Watchers(Resource): @@ -1096,7 +1096,7 @@ def __init__( Resource.__init__(self, "issue/{0}/watchers", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def delete(self, username): # type: ignore[override] """Remove the specified user from the watchers list.""" @@ -1114,7 +1114,7 @@ def __init__( self.remainingEstimate = None if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Worklog(Resource): @@ -1129,7 +1129,7 @@ def __init__( Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def delete( # type: ignore[override] self, adjustEstimate: str | None = None, newEstimate=None, increaseBy=None @@ -1166,7 +1166,7 @@ def __init__( Resource.__init__(self, "issue/{0}/properties/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def _find_by_url( self, @@ -1190,7 +1190,7 @@ def __init__( Resource.__init__(self, "issueLink/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class IssueLinkType(Resource): @@ -1205,7 +1205,7 @@ def __init__( Resource.__init__(self, "issueLinkType/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class IssueType(Resource): @@ -1220,7 +1220,7 @@ def __init__( Resource.__init__(self, "issuetype/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Priority(Resource): @@ -1235,7 +1235,7 @@ def __init__( Resource.__init__(self, "priority/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Project(Resource): @@ -1250,7 +1250,7 @@ def __init__( Resource.__init__(self, "project/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Role(Resource): @@ -1265,7 +1265,7 @@ def __init__( Resource.__init__(self, "project/{0}/role/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] self, @@ -1325,7 +1325,7 @@ def __init__( Resource.__init__(self, "resolution/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class SecurityLevel(Resource): @@ -1340,7 +1340,7 @@ def __init__( Resource.__init__(self, "securitylevel/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Status(Resource): @@ -1355,7 +1355,7 @@ def __init__( Resource.__init__(self, "status/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class StatusCategory(Resource): @@ -1370,7 +1370,7 @@ def __init__( Resource.__init__(self, "statuscategory/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class User(Resource): @@ -1391,7 +1391,7 @@ def __init__( Resource.__init__(self, f"user?{_query_param}" + "={0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Group(Resource): @@ -1406,7 +1406,7 @@ def __init__( Resource.__init__(self, "group?groupname={0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Version(Resource): @@ -1421,7 +1421,7 @@ def __init__( Resource.__init__(self, "version/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): """Delete this project version from the server. @@ -1493,7 +1493,7 @@ def __init__( Resource.__init__(self, path, options, session, self.AGILE_BASE_URL) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Sprint(AgileResource): @@ -1537,7 +1537,7 @@ def __init__( ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class ServiceDesk(Resource): @@ -1558,7 +1558,7 @@ def __init__( ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class RequestType(Resource): @@ -1580,7 +1580,7 @@ def __init__( if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) # Utilities @@ -1602,9 +1602,9 @@ def dict2resource( if isinstance(j, dict): if "self" in j: # to try and help mypy know that cls_for_resource can never be 'Resource' - resource_class = cast(Type[Resource], cls_for_resource(j["self"])) + resource_class = cast(type[Resource], cls_for_resource(j["self"])) resource = cast( - Type[Resource], + type[Resource], resource_class( # type: ignore options=options, session=session, @@ -1617,17 +1617,17 @@ def dict2resource( else: setattr(top, i, dict2resource(j, options=options, session=session)) elif isinstance(j, seqs): - j = cast(List[Dict[str, Any]], j) # help mypy + j = cast(list[dict[str, Any]], j) # help mypy seq_list: list[Any] = [] for seq_elem in j: if isinstance(seq_elem, dict): if "self" in seq_elem: # to try and help mypy know that cls_for_resource can never be 'Resource' resource_class = cast( - Type[Resource], cls_for_resource(seq_elem["self"]) + type[Resource], cls_for_resource(seq_elem["self"]) ) resource = cast( - Type[Resource], + type[Resource], resource_class( # type: ignore options=options, session=session, @@ -1700,7 +1700,7 @@ def __init__( Resource.__init__(self, "unknown{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def cls_for_resource(resource_literal: str) -> type[Resource]: diff --git a/pyproject.toml b/pyproject.toml index 2260e162d..8878969f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "jira" authors = [{name = "Ben Speakmon", email = "ben.speakmon@gmail.com"}] maintainers = [{name = "Sorin Sbarnea", email = "sorin.sbarnea@gmail.com"}] description = "Python library for interacting with JIRA via REST APIs." -requires-python = ">=3.8" +requires-python = ">=3.9" license = {text = "BSD-2-Clause"} classifiers = [ "Development Status :: 5 - Production/Stable", @@ -15,10 +15,10 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP", ] @@ -163,7 +163,7 @@ filterwarnings = ["ignore::pytest.PytestWarning"] markers = ["allow_on_cloud: opt in for the test to run on Jira Cloud"] [tool.mypy] -python_version = "3.8" +python_version = "3.9" warn_unused_configs = true namespace_packages = true check_untyped_defs = true @@ -177,8 +177,8 @@ disable_error_code = "annotation-unchecked" # Same as Black. line-length = 88 -# Assume Python 3.8. (minimum supported) -target-version = "py38" +# Assume Python 3.9 (minimum supported) +target-version = "py39" # The source code paths to consider, e.g., when resolving first- vs. third-party imports src = ["jira", "tests"] @@ -204,6 +204,8 @@ ignore = [ "D401", "D402", "D417", + "UP006", + "UP035", ] # Allow unused variables when underscore-prefixed. diff --git a/tests/resources/test_board.py b/tests/resources/test_board.py index 8393d43cf..183d84fe2 100644 --- a/tests/resources/test_board.py +++ b/tests/resources/test_board.py @@ -1,7 +1,7 @@ from __future__ import annotations +from collections.abc import Iterator from contextlib import contextmanager -from typing import Iterator from jira.resources import Board from tests.conftest import JiraTestCase, rndstr diff --git a/tests/resources/test_epic.py b/tests/resources/test_epic.py index e38e42c07..8d82c2b86 100644 --- a/tests/resources/test_epic.py +++ b/tests/resources/test_epic.py @@ -1,8 +1,8 @@ from __future__ import annotations +from collections.abc import Iterator from contextlib import contextmanager from functools import cached_property -from typing import Iterator from parameterized import parameterized diff --git a/tests/resources/test_sprint.py b/tests/resources/test_sprint.py index 83c0d43b1..2ccb14837 100644 --- a/tests/resources/test_sprint.py +++ b/tests/resources/test_sprint.py @@ -1,8 +1,8 @@ from __future__ import annotations +from collections.abc import Iterator from contextlib import contextmanager from functools import lru_cache -from typing import Iterator import pytest as pytest diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 191c6614b..088ff6dc1 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -116,9 +116,11 @@ def test_jira_error_log_to_tempfile_if_env_var_set(self): # WHEN: a JIRAError's __str__ method is called and # log details are expected to be sent to the tempfile - with patch.dict("os.environ", env_vars), patch( - f"{PATCH_BASE}.tempfile.mkstemp", autospec=True - ) as mock_mkstemp, patch(f"{PATCH_BASE}.open", mocked_open): + with ( + patch.dict("os.environ", env_vars), + patch(f"{PATCH_BASE}.tempfile.mkstemp", autospec=True) as mock_mkstemp, + patch(f"{PATCH_BASE}.open", mocked_open), + ): mock_mkstemp.return_value = 0, str(test_jira_error_filename) str(JIRAError(response=self.MockResponse(text=DUMMY_TEXT))) @@ -137,9 +139,11 @@ def test_jira_error_log_to_tempfile_not_used_if_env_var_not_set(self): mocked_open = mock_open() # WHEN: a JIRAError's __str__ method is called - with patch.dict("os.environ", env_vars), patch( - f"{PATCH_BASE}.tempfile.mkstemp", autospec=True - ) as mock_mkstemp, patch(f"{PATCH_BASE}.open", mocked_open): + with ( + patch.dict("os.environ", env_vars), + patch(f"{PATCH_BASE}.tempfile.mkstemp", autospec=True) as mock_mkstemp, + patch(f"{PATCH_BASE}.open", mocked_open), + ): mock_mkstemp.return_value = 0, str(test_jira_error_filename) str(JIRAError(response=self.MockResponse(text=DUMMY_TEXT))) diff --git a/tox.ini b/tox.ini index 8e7441699..e11430199 100644 --- a/tox.ini +++ b/tox.ini @@ -2,27 +2,25 @@ minversion = 4.0 isolated_build = True requires = - # plugins disabled until they gets compatible with tox v4 - # tox-extra - # tox-pyenv + tox-extra envlist = lint pkg docs + py312 py311 py310 py39 - py38 ignore_basepython_conflict = True skip_missing_interpreters = True skipdist = True [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 [testenv] @@ -72,13 +70,13 @@ allowlist_externals = description = Update dependency lock files # Force it to use oldest supported version of python or we would lose ability # to get pinning correctly. -basepython = python3.8 +basepython = python3.9 skip_install = true deps = pip-tools >= 6.4.0 pre-commit >= 2.13.0 commands = - pip-compile --upgrade -o constraints.txt setup.cfg --extra cli --extra docs --extra opt --extra async --extra test --strip-extras + pip-compile --upgrade -o constraints.txt --extra cli --extra docs --extra opt --extra async --extra test --strip-extras {envpython} -m pre_commit autoupdate [testenv:docs] From 84071a48eafb78a24ae7ca4380447403ef0fb39d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 29 Aug 2024 10:47:29 +0100 Subject: [PATCH 06/10] [pre-commit.ci] pre-commit autoupdate (#1887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/mirrors-mypy: v1.11.1 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.1...v1.11.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ff461314..7c00d4df0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: yamllint files: \.(yaml|yml)$ - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.1 + rev: v1.11.2 hooks: - id: mypy additional_dependencies: From 196194a375ae080943cd514226fd93f306d9597e Mon Sep 17 00:00:00 2001 From: Kristian Mosti Date: Fri, 30 Aug 2024 16:52:22 +0200 Subject: [PATCH 07/10] fix(parse-errors): ensure if block execution (#1854) When error messages contains more than one object, ensure that they are all parsed. --- jira/resilientsession.py | 6 +++--- tests/test_resilientsession.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/jira/resilientsession.py b/jira/resilientsession.py index f5447f2fa..306f02b19 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -105,11 +105,11 @@ def parse_errors(resp: Response) -> list[str]: if "message" in resp_data: # Jira 5.1 errors parsed_errors = [resp_data["message"]] - elif "errorMessage" in resp_data: + if "errorMessage" in resp_data: # Sometimes Jira returns `errorMessage` as a message error key # for example for the "Service temporary unavailable" error parsed_errors = [resp_data["errorMessage"]] - elif "errorMessages" in resp_data: + if "errorMessages" in resp_data: # Jira 5.0.x error messages sometimes come wrapped in this array # Sometimes this is present but empty error_messages = resp_data["errorMessages"] @@ -118,7 +118,7 @@ def parse_errors(resp: Response) -> list[str]: parsed_errors = list(error_messages) else: parsed_errors = [error_messages] - elif "errors" in resp_data: + if "errors" in resp_data: resp_errors = resp_data["errors"] if len(resp_errors) > 0 and isinstance(resp_errors, dict): # Catching only 'errors' that are dict. See https://github.com/pycontribs/jira/issues/350 diff --git a/tests/test_resilientsession.py b/tests/test_resilientsession.py index 4a7bbd800..7762ec8fa 100644 --- a/tests/test_resilientsession.py +++ b/tests/test_resilientsession.py @@ -159,6 +159,12 @@ def test_status_codes_retries( (500, {}, '{"errorMessages": "err1"}', ["err1"]), (500, {}, '{"errorMessages": ["err1", "err2"]}', ["err1", "err2"]), (500, {}, '{"errors": {"code1": "err1", "code2": "err2"}}', ["err1", "err2"]), + ( + 500, + {}, + '{"errorMessages": [], "errors": {"code1": "err1", "code2": "err2"}}', + ["err1", "err2"], + ), ] From 91b5c27b66620b2011eac4e4fd4b562ae8c5e663 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Sat, 31 Aug 2024 11:00:18 +0100 Subject: [PATCH 08/10] Remove pillow as a dependency (#1891) --- .pre-commit-config.yaml | 2 +- constraints.txt | 6 ++---- jira/client.py | 7 +++++-- pyproject.toml | 1 - 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c00d4df0..3592a9cf1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: additional_dependencies: - tomli; python_version<'3.11' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.2" + rev: "v0.6.3" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/constraints.txt b/constraints.txt index 589254686..4940cd460 100644 --- a/constraints.txt +++ b/constraints.txt @@ -14,7 +14,7 @@ backports-tarfile==1.2.0 # via jaraco-context beautifulsoup4==4.12.3 # via furo -certifi==2024.7.4 +certifi==2024.8.30 # via requests cffi==1.17.0 # via cryptography @@ -106,8 +106,6 @@ parso==0.8.4 # via jedi pexpect==4.9.0 # via ipython -pillow==10.4.0 - # via jira (pyproject.toml) pluggy==1.5.0 # via pytest prompt-toolkit==3.0.47 @@ -232,5 +230,5 @@ wheel==0.44.0 # via jira (pyproject.toml) yanc==0.3.3 # via jira (pyproject.toml) -zipp==3.20.0 +zipp==3.20.1 # via importlib-metadata diff --git a/jira/client.py b/jira/client.py index 851b7f5d8..dab6ede40 100644 --- a/jira/client.py +++ b/jira/client.py @@ -18,6 +18,7 @@ import os import re import sys +import tempfile import time import urllib import warnings @@ -40,7 +41,6 @@ import requests from packaging.version import parse as parse_version -from PIL import Image from requests import Response from requests.auth import AuthBase from requests.structures import CaseInsensitiveDict @@ -4422,7 +4422,10 @@ def _get_mime_type(self, buff: bytes) -> str | None: if self._magic is not None: return self._magic.id_buffer(buff) try: - return mimetypes.guess_type("f." + Image.open(buff).format)[0] + with tempfile.TemporaryFile() as f: + f.write(buff) + return mimetypes.guess_type(f.name)[0] + return mimetypes.guess_type(f.name)[0] except (OSError, TypeError): self.log.warning( "Couldn't detect content type of avatar image" diff --git a/pyproject.toml b/pyproject.toml index 8878969f9..241842f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ keywords = ["api", "atlassian", "jira", "rest", "web"] dependencies = [ "defusedxml", "packaging", - "Pillow>=2.1.0", "requests-oauthlib>=1.1.0", "requests>=2.10.0", "requests_toolbelt", From afbced5ecce74774071540c878261221955f1094 Mon Sep 17 00:00:00 2001 From: Marcel Prestel Date: Sun, 1 Sep 2024 21:48:37 +0200 Subject: [PATCH 09/10] Add pin/unpin & pinned_comments (#1888) --------- Co-authored-by: m.prestel --- jira/client.py | 34 ++++++++++++++++++++++++++ jira/resources.py | 17 +++++++++++++ tests/resources/test_pinned_comment.py | 34 ++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 tests/resources/test_pinned_comment.py diff --git a/jira/client.py b/jira/client.py index dab6ede40..0964d2b2d 100644 --- a/jira/client.py +++ b/jira/client.py @@ -74,6 +74,7 @@ IssueTypeScheme, NotificationScheme, PermissionScheme, + PinnedComment, Priority, PriorityScheme, Project, @@ -5637,3 +5638,36 @@ def move_to_backlog(self, issue_keys: list[str]) -> Response: url = self._get_url("backlog/issue", base=self.AGILE_BASE_URL) payload = {"issues": issue_keys} # TODO: should be list of issues return self._session.post(url, data=json.dumps(payload)) + + @translate_resource_args + def pinned_comments(self, issue: int | str) -> list[PinnedComment]: + """Get a list of pinned comment Resources of the issue provided. + + Args: + issue (Union[int, str]): the issue ID or key to get the comments from + + Returns: + List[PinnedComment] + """ + r_json = self._get_json(f"issue/{issue}/pinned-comments", params={}) + + pinned_comments = [ + PinnedComment(self._options, self._session, raw_comment_json) + for raw_comment_json in r_json + ] + return pinned_comments + + @translate_resource_args + def pin_comment(self, issue: int | str, comment: int | str, pin: bool) -> Response: + """Pin/Unpin a comment on the issue. + + Args: + issue (Union[int, str]): the issue ID or key to get the comments from + comment (Union[int, str]): the comment ID + pin (bool): Pin (True) or Unpin (False) + + Returns: + Response + """ + url = self._get_url("issue/" + str(issue) + "/comment/" + str(comment) + "/pin") + return self._session.put(url, data=str(pin).lower()) diff --git a/jira/resources.py b/jira/resources.py index e72b7f456..29c86e604 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -69,6 +69,7 @@ class AnyLike: "ServiceDesk", "RequestType", "resource_class_map", + "PinnedComment", ) logging.getLogger("jira").addHandler(logging.NullHandler()) @@ -955,6 +956,21 @@ def update( # type: ignore[override] super().update(async_=async_, jira=jira, notify=notify, fields=data) +class PinnedComment(Resource): + """Pinned comment on an issue.""" + + def __init__( + self, + options: dict[str, str], + session: ResilientSession, + raw: dict[str, Any] | None = None, + ): + Resource.__init__(self, "issue/{0}/pinned-comments", options, session) + if raw: + self._parse_raw(raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) + + class RemoteLink(Resource): """A link to a remote application from an issue.""" @@ -1659,6 +1675,7 @@ def dict2resource( r"filter/[^/]$": Filter, r"issue/[^/]+$": Issue, r"issue/[^/]+/comment/[^/]+$": Comment, + r"issue/[^/]+/pinned-comments$": PinnedComment, r"issue/[^/]+/votes$": Votes, r"issue/[^/]+/watchers$": Watchers, r"issue/[^/]+/worklog/[^/]+$": Worklog, diff --git a/tests/resources/test_pinned_comment.py b/tests/resources/test_pinned_comment.py new file mode 100644 index 000000000..5170d89f6 --- /dev/null +++ b/tests/resources/test_pinned_comment.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from tests.conftest import JiraTestCase + + +class PinnedCommentTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1_key = self.test_manager.project_b_issue1 + self.issue_2_key = self.test_manager.project_b_issue2 + self.issue_3_key = self.test_manager.project_b_issue3 + + def tearDown(self) -> None: + for issue in [self.issue_1_key, self.issue_2_key, self.issue_3_key]: + for comment in self.jira.comments(issue): + comment.delete() + + def test_pincomments(self): + for issue in [self.issue_1_key, self.jira.issue(self.issue_2_key)]: + self.jira.issue(issue) + comment1 = self.jira.add_comment(issue, "First comment") + self.jira.pin_comment(comment1.id, True) + comment2 = self.jira.add_comment(issue, "Second comment") + self.jira.pin_comment(comment2.id, True) + pinned_comments = self.jira.pinned_comments(issue) + assert pinned_comments[0].comment.body == "First comment" + assert pinned_comments[1].comment.body == "Second comment" + self.jira.pin_comment(comment1.id, False) + pinned_comments = self.jira.pinned_comments(issue) + assert len(pinned_comments) == 1 + assert pinned_comments[0].comment.body == "Second comment" + self.jira.pin_comment(comment2.id, False) + pinned_comments = self.jira.pinned_comments(issue) + assert len(pinned_comments) == 0 From 9900396455e749de7c7ba97801cee024c8c1aeec Mon Sep 17 00:00:00 2001 From: StealthCT Date: Mon, 2 Sep 2024 14:15:19 +0100 Subject: [PATCH 10/10] Provide overload type hints for search_issues variants (#1861) --- examples/auth.py | 5 +---- jira/client.py | 31 +++++++++++++++++++++++++++++++ tests/tests.py | 13 +++++++------ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/examples/auth.py b/examples/auth.py index cda716403..2cb53a8f5 100644 --- a/examples/auth.py +++ b/examples/auth.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections import Counter -from typing import cast from jira import JIRA from jira.client import ResultList @@ -25,9 +24,7 @@ props = jira.application_properties() # Find all issues reported by the admin -# Note: we cast() for mypy's benefit, as search_issues can also return the raw json ! -# This is if the following argument is used: `json_result=True` -issues = cast(ResultList[Issue], jira.search_issues("assignee=admin")) +issues: ResultList[Issue] = jira.search_issues("assignee=admin") # Find the top three projects containing issues reported by admin top_three = Counter([issue.fields.project.key for issue in issues]).most_common(3) diff --git a/jira/client.py b/jira/client.py index 0964d2b2d..36dc2fea9 100644 --- a/jira/client.py +++ b/jira/client.py @@ -3480,6 +3480,22 @@ def resolution(self, id: str) -> Resolution: # Search + @overload + def search_issues( + self, + jql_str: str, + startAt: int = 0, + maxResults: int = 50, + validate_query: bool = True, + fields: str | list[str] | None = "*all", + expand: str | None = None, + properties: str | None = None, + *, + json_result: Literal[False] = False, + use_post: bool = False, + ) -> ResultList[Issue]: ... + + @overload def search_issues( self, jql_str: str, @@ -3489,6 +3505,21 @@ def search_issues( fields: str | list[str] | None = "*all", expand: str | None = None, properties: str | None = None, + *, + json_result: Literal[True], + use_post: bool = False, + ) -> dict[str, Any]: ... + + def search_issues( + self, + jql_str: str, + startAt: int = 0, + maxResults: int = 50, + validate_query: bool = True, + fields: str | list[str] | None = "*all", + expand: str | None = None, + properties: str | None = None, + *, json_result: bool = False, use_post: bool = False, ) -> dict[str, Any] | ResultList[Issue]: diff --git a/tests/tests.py b/tests/tests.py index bbc16c722..11dd30b7f 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -23,7 +23,6 @@ from parameterized import parameterized from jira import JIRA, Issue, JIRAError -from jira.client import ResultList from jira.resources import Dashboard, Resource, cls_for_resource from tests.conftest import JiraTestCase, allow_on_cloud, rndpassword @@ -231,11 +230,17 @@ def setUp(self): def test_search_issues(self): issues = self.jira.search_issues(f"project={self.project_b}") - issues = cast(ResultList[Issue], issues) self.assertLessEqual(len(issues), 50) # default maxResults for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) + def test_search_issues_json(self): + result = self.jira.search_issues(f"project={self.project_b}", json_result=True) + issues = result["issues"] + self.assertLessEqual(len(issues), 50) # default maxResults + for issue in issues: + self.assertTrue(issue["key"].startswith(self.project_b)) + def test_search_issues_async(self): original_val = self.jira._options["async"] try: @@ -243,7 +248,6 @@ def test_search_issues_async(self): issues = self.jira.search_issues( f"project={self.project_b}", maxResults=False ) - issues = cast(ResultList[Issue], issues) self.assertEqual(len(issues), issues.total) for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) @@ -263,7 +267,6 @@ def test_search_issues_startat(self): def test_search_issues_field_limiting(self): issues = self.jira.search_issues(f"key={self.issue}", fields="summary,comment") - issues = cast(ResultList[Issue], issues) self.assertTrue(hasattr(issues[0].fields, "summary")) self.assertTrue(hasattr(issues[0].fields, "comment")) self.assertFalse(hasattr(issues[0].fields, "reporter")) @@ -271,7 +274,6 @@ def test_search_issues_field_limiting(self): def test_search_issues_expand(self): issues = self.jira.search_issues(f"key={self.issue}", expand="changelog") - issues = cast(ResultList[Issue], issues) # self.assertTrue(hasattr(issues[0], 'names')) self.assertEqual(len(issues), 1) self.assertFalse(hasattr(issues[0], "editmeta")) @@ -283,7 +285,6 @@ def test_search_issues_use_post(self): with pytest.raises(JIRAError): self.jira.search_issues(long_jql) issues = self.jira.search_issues(long_jql, use_post=True) - issues = cast(ResultList[Issue], issues) self.assertEqual(len(issues), 1) self.assertEqual(issues[0].key, self.issue)