Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-jira for openSUSE:Factory checked in at 2024-04-03 17:20:48 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-jira (Old) and /work/SRC/openSUSE:Factory/.python-jira.new.1905 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-jira" Wed Apr 3 17:20:48 2024 rev:14 rq:1164275 version:3.8.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-jira/python-jira.changes 2024-03-25 21:14:06.702210511 +0100 +++ /work/SRC/openSUSE:Factory/.python-jira.new.1905/python-jira.changes 2024-04-03 17:22:05.309197282 +0200 @@ -1,0 +2,13 @@ +Wed Apr 3 07:24:10 UTC 2024 - Dirk Müller <[email protected]> + +- update to 3.8.0: + * Add goal field to update/create sprint (#1806) @zbarahal + * add backward compatibility for createmeta_issuetypes & + createmeta_fieldtypes (#1838) @paminov +- update to 3.7.0: + * add Release Process doc + * Improve handling of Jira's retry-after handling + * ISSUE-1836: Add `Dashboard` Support + * Update createmeta warning with new method names + +------------------------------------------------------------------- Old: ---- jira-3.6.0.tar.gz New: ---- jira-3.8.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-jira.spec ++++++ --- /var/tmp/diff_new_pack.ONEBNM/_old 2024-04-03 17:22:05.893218830 +0200 +++ /var/tmp/diff_new_pack.ONEBNM/_new 2024-04-03 17:22:05.893218830 +0200 @@ -17,7 +17,7 @@ Name: python-jira -Version: 3.6.0 +Version: 3.8.0 Release: 0 Summary: Python library for interacting with JIRA via REST APIs License: BSD-3-Clause ++++++ jira-3.6.0.tar.gz -> jira-3.8.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/.readthedocs.yml new/jira-3.8.0/.readthedocs.yml --- old/jira-3.6.0/.readthedocs.yml 2024-01-05 18:17:52.000000000 +0100 +++ new/jira-3.8.0/.readthedocs.yml 2024-03-25 13:16:36.000000000 +0100 @@ -7,16 +7,13 @@ os: ubuntu-22.04 tools: python: "3.11" - jobs: - # Work-around to actually constrain dependencies - # https://github.com/readthedocs/readthedocs.org/issues/7258#issuecomment-1094978683 - post_install: - - python -m pip install --upgrade --upgrade-strategy eager --no-cache-dir .[docs,cli] -c constraints.txt + apt_packages: + - libkrb5-dev python: install: - - method: pip - path: . + - requirements: constraints.txt + - path: . extra_requirements: - docs # to autodoc jirashell diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/PKG-INFO new/jira-3.8.0/PKG-INFO --- old/jira-3.6.0/PKG-INFO 2024-01-05 18:18:04.238345900 +0100 +++ new/jira-3.8.0/PKG-INFO 2024-03-25 13:16:45.864503400 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: jira -Version: 3.6.0 +Version: 3.8.0 Summary: Python library for interacting with JIRA via REST APIs. Home-page: https://github.com/pycontribs/jira Author: Ben Speakmon diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/RELEASE.md new/jira-3.8.0/RELEASE.md --- old/jira-3.6.0/RELEASE.md 1970-01-01 01:00:00.000000000 +0100 +++ new/jira-3.8.0/RELEASE.md 2024-03-25 13:16:36.000000000 +0100 @@ -0,0 +1,25 @@ +# Documenting the Release Process + +## Scope + +The scope and purpose of these instructions are for maintainers who are looking to make +a release. + +It forms a checklist of what needs to be done, together with sections for guidance. + +- [ ] [Draft a New Release](#drafting-a-new-release) +- [ ] [Publishing the Release](#publishing-the-release) + + +## Drafting a New Release + +1. Go to the https://github.com/pycontribs/jira/releases page and **"Draft a New Release"** using the button. Note: We currently use the 'Release Drafter' GHA that auto populates a draft release +2. Under the **"Choose a tag"**, make sure we follow the repository convention of a tag that IS NOT PREFIXED with a `v` e.g. `1.2.3` instead of `v1.2.3` +3. The tag **"Target"** should be `main`, the main branch. +3. The contents of the release should reference the PR reference and the individual who contributed. In some cases where maintainers take over a previous PR it is better practice to reference the name of the original submitter of the PR. e.g. The maintainer re-makes a PR based on a stale PR, the GHA would mention the maintainer by default as they created the PR, so the originator should be used. + +## Publishing the Release + +1. Pressing the **Edit** button of the latest draft release and pressing the **'Publish release'** button will trigger the release process. +2. The release process will request an approver from the list of release approvers. These are the maintainers specifically added here: https://github.com/pycontribs/jira/settings/environments/333132378/edit. This release environment also limits the branches that can be deployed. To follow the tag version convention mentioned earlier. +3. Finally this will automatically trigger the release CI action (as defined in our repository), this uses the relevant repository secrets to publish to PyPI. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/constraints.txt new/jira-3.8.0/constraints.txt --- old/jira-3.6.0/constraints.txt 2024-01-05 18:17:52.000000000 +0100 +++ new/jira-3.8.0/constraints.txt 2024-03-25 13:16:36.000000000 +0100 @@ -12,7 +12,7 @@ # via sphinx backcall==0.2.0 # via ipython -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via furo certifi==2023.11.17 # via requests @@ -71,7 +71,7 @@ # via keyring jedi==0.19.1 # via ipython -jinja2==3.1.2 +jinja2==3.1.3 # via sphinx keyring==24.3.0 # via jira (setup.cfg) @@ -192,7 +192,7 @@ # via jira (setup.cfg) sphinxcontrib-applehelp==1.0.4 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==1.0.6 # via sphinx sphinxcontrib-htmlhelp==2.0.4 # via sphinx @@ -200,7 +200,7 @@ # via sphinx sphinxcontrib-qthelp==1.0.6 # via sphinx -sphinxcontrib-serializinghtml==1.1.9 +sphinxcontrib-serializinghtml==1.1.10 # via sphinx sspilib==0.1.0 # via pyspnego diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/jira/client.py new/jira-3.8.0/jira/client.py --- old/jira-3.6.0/jira/client.py 2024-01-05 18:17:52.000000000 +0100 +++ new/jira-3.8.0/jira/client.py 2024-03-25 13:16:36.000000000 +0100 @@ -5,6 +5,7 @@ will construct a JIRA object as described below. Full API documentation can be found at: https://jira.readthedocs.io/en/latest/. """ + from __future__ import annotations import calendar @@ -49,7 +50,7 @@ from requests_toolbelt import MultipartEncoder from jira import __version__ -from jira.exceptions import JIRAError +from jira.exceptions import JIRAError, NotJIRAInstanceError from jira.resilientsession import PrepareRequestForRetry, ResilientSession from jira.resources import ( AgileResource, @@ -60,6 +61,9 @@ Customer, CustomFieldOption, Dashboard, + DashboardGadget, + DashboardItemProperty, + DashboardItemPropertyKey, Field, Filter, Group, @@ -92,7 +96,7 @@ WorkflowScheme, Worklog, ) -from jira.utils import json_loads, threaded_requests +from jira.utils import json_loads, remove_empty_attributes, threaded_requests try: from requests_jwt import JWTAuth @@ -104,6 +108,82 @@ LOG.addHandler(_logging.NullHandler()) +def cloud_api(client_method: Callable) -> Callable: + """A convenience decorator to check if the Jira instance is cloud. + + Checks if the client instance is talking to Cloud Jira. If it is, return + the result of the called client method. If not, return None and log a + warning. + + Args: + client_method: The method that is being called by the client. + + Returns: + Either the result of the wrapped function or None. + + Raises: + JIRAError: In the case the error is not an HTTP error with a status code. + NotJIRAInstanceError: In the case that the first argument to this method + is not a `client.JIRA` instance. + """ + wraps(client_method) + + def check_if_cloud(*args, **kwargs): + # The first argument of any class instance is a `self` + # reference. Avoiding magic numbers here. + instance = next(arg for arg in args) + if not isinstance(instance, JIRA): + raise NotJIRAInstanceError(instance) + + if instance._is_cloud: + return client_method(*args, **kwargs) + + instance.log.warning( + "This functionality is not available on Jira Data Center (Server) version." + ) + return None + + return check_if_cloud + + +def experimental_atlassian_api(client_method: Callable) -> Callable: + """A convenience decorator to inform if a client method is experimental. + + Indicates the path covered by the client method is experimental. If the path + disappears or the method becomes disallowed, this logs an error and returns + None. If another kind of exception is raised, this reraises. + + Raises: + JIRAError: In the case the error is not an HTTP error with a status code. + NotJIRAInstanceError: In the case that the first argument to this method is + is not a `client.JIRA` instance. + + Returns: + Either the result of the wrapped function or None. + """ + wraps(client_method) + + def is_experimental(*args, **kwargs): + instance = next(arg for arg in args) + if not isinstance(instance, JIRA): + raise NotJIRAInstanceError(instance) + + try: + return client_method(*args, **kwargs) + except JIRAError as e: + response = getattr(e, "response", None) + if response is not None and response.status_code in [405, 404]: + instance.log.warning( + f"Functionality at path {response.url} is/was experimental. " + f"Status Code: {response.status_code}" + ) + return None + else: + raise + + return is_experimental + + def translate_resource_args(func: Callable): """Decorator that converts Issue and Project resources to their keys when used as arguments. @@ -1180,7 +1260,7 @@ maxResults (int): maximum number of dashboards to return. If maxResults set to False, it will try to get all items in batches. (Default: ``20``) Returns: - ResultList + ResultList[Dashboard] """ params = {} if filter is not None: @@ -1203,7 +1283,253 @@ Returns: Dashboard """ - return self._find_for_resource(Dashboard, id) + dashboard = self._find_for_resource(Dashboard, id) + dashboard.gadgets.extend(self.dashboard_gadgets(id) or []) + return dashboard + + @cloud_api + @experimental_atlassian_api + def create_dashboard( + self, + name: str, + description: str | None = None, + edit_permissions: list[dict] | list | None = None, + share_permissions: list[dict] | list | None = None, + ) -> Dashboard: + """Create a new dashboard and return a dashboard resource for it. + + Args: + name (str): Name of the new dashboard `required`. + description (Optional[str]): Useful human-readable description of the new dashboard. + edit_permissions (list | list[dict]): A list of permissions dicts `required` + though can be an empty list. + share_permissions (list | list[dict]): A list of permissions dicts `required` + though can be an empty list. + + Returns: + Dashboard + """ + data: dict[str, Any] = remove_empty_attributes( + { + "name": name, + "editPermissions": edit_permissions or [], + "sharePermissions": share_permissions or [], + "description": description, + } + ) + url = self._get_url("dashboard") + r = self._session.post(url, data=json.dumps(data)) + + raw_dashboard_json: dict[str, Any] = json_loads(r) + return Dashboard(self._options, self._session, raw=raw_dashboard_json) + + @cloud_api + @experimental_atlassian_api + def copy_dashboard( + self, + id: str, + name: str, + description: str | None = None, + edit_permissions: list[dict] | list | None = None, + share_permissions: list[dict] | list | None = None, + ) -> Dashboard: + """Copy an existing dashboard. + + Args: + id (str): The ``id`` of the ``Dashboard`` to copy. + name (str): Name of the new dashboard `required`. + description (Optional[str]): Useful human-readable description of the new dashboard. + edit_permissions (list | list[dict]): A list of permissions dicts `required` + though can be an empty list. + share_permissions (list | list[dict]): A list of permissions dicts `required` + though can be an empty list. + + Returns: + Dashboard + """ + data: dict[str, Any] = remove_empty_attributes( + { + "name": name, + "editPermissions": edit_permissions or [], + "sharePermissions": share_permissions or [], + "description": description, + } + ) + url = self._get_url("dashboard") + url = f"{url}/{id}/copy" + r = self._session.post(url, json=data) + + raw_dashboard_json: dict[str, Any] = json_loads(r) + return Dashboard(self._options, self._session, raw=raw_dashboard_json) + + @cloud_api + @experimental_atlassian_api + def update_dashboard_automatic_refresh_minutes( + self, id: str, minutes: int + ) -> Response: + """Update the automatic refresh interval of a dashboard. + + Args: + id (str): The ``id`` of the ``Dashboard`` to copy. + minutes (int): The frequency of the dashboard automatic refresh in minutes. + + Returns: + Response + """ + # The payload expects milliseconds, we are doing a conversion + # here as a convenience. Additionally, if the value is `0` then we are setting + # to `None` which will serialize to `null` in `json` which is what is + # expected if the user wants to turn it off. + + value = minutes * 60000 if minutes else None + data = {"automaticRefreshMs": value} + + url = self._get_internal_url(f"dashboards/{id}/automatic-refresh-ms") + return self._session.put(url, json=data) + + def dashboard_item_property_keys( + self, dashboard_id: str, item_id: str + ) -> ResultList[DashboardItemPropertyKey]: + """Return a ResultList of a Dashboard gadget's property keys. + + Args: + dashboard_id (str): ID of dashboard. + item_id (str): ID of dashboard item (``DashboardGadget``). + + Returns: + ResultList[DashboardItemPropertyKey] + """ + return self._fetch_pages( + DashboardItemPropertyKey, + "keys", + f"dashboard/{dashboard_id}/items/{item_id}/properties", + ) + + def dashboard_item_property( + self, dashboard_id: str, item_id: str, property_key: str + ) -> DashboardItemProperty: + """Get the item property for a specific dashboard item (DashboardGadget). + + Args: + dashboard_id (str): of the dashboard. + item_id (str): ID of the item (``DashboardGadget``) on the dashboard. + property_key (str): KEY of the gadget property. + + Returns: + DashboardItemProperty + """ + dashboard_item_property = self._find_for_resource( + DashboardItemProperty, (dashboard_id, item_id, property_key) + ) + return dashboard_item_property + + def set_dashboard_item_property( + self, dashboard_id: str, item_id: str, property_key: str, value: dict[str, Any] + ) -> DashboardItemProperty: + """Set a dashboard item property. + + Args: + dashboard_id (str): Dashboard id. + item_id (str): ID of dashboard item (``DashboardGadget``) to add property_key to. + property_key (str): The key of the property to set. + value (dict[str, Any]): The dictionary containing the value of the property key. + + Returns: + DashboardItemProperty + """ + url = self._get_url( + f"dashboard/{dashboard_id}/items/{item_id}/properties/{property_key}" + ) + r = self._session.put(url, json=value) + + if not r.ok: + raise JIRAError(status_code=r.status_code, request=r) + return self.dashboard_item_property(dashboard_id, item_id, property_key) + + @cloud_api + @experimental_atlassian_api + def dashboard_gadgets(self, dashboard_id: str) -> list[DashboardGadget]: + """Return a list of DashboardGadget resources for the specified dashboard. + + Args: + dashboard_id (str): the ``dashboard_id`` of the dashboard to get gadgets for + + Returns: + list[DashboardGadget] + """ + gadgets: list[DashboardGadget] = [] + gadgets = self._fetch_pages( + DashboardGadget, "gadgets", f"dashboard/{dashboard_id}/gadget" + ) + for gadget in gadgets: + for dashboard_item_key in self.dashboard_item_property_keys( + dashboard_id, gadget.id + ): + gadget.item_properties.append( + self.dashboard_item_property( + dashboard_id, gadget.id, dashboard_item_key.key + ) + ) + + return gadgets + + @cloud_api + @experimental_atlassian_api + def all_dashboard_gadgets(self) -> ResultList[DashboardGadget]: + """Return a ResultList of available DashboardGadget resources and a ``total`` count. + + Returns: + ResultList[DashboardGadget] + """ + return self._fetch_pages(DashboardGadget, "gadgets", "dashboard/gadgets") + + @cloud_api + @experimental_atlassian_api + def add_gadget_to_dashboard( + self, + dashboard_id: str | Dashboard, + color: str | None = None, + ignore_uri_and_module_key_validation: bool | None = None, + module_key: str | None = None, + position: dict[str, int] | None = None, + title: str | None = None, + uri: str | None = None, + ) -> DashboardGadget: + """Add a gadget to a dashboard and return a ``DashboardGadget`` resource. + + Args: + dashboard_id (str): The ``dashboard_id`` of the dashboard to add the gadget to `required`. + color (str): The color of the gadget, should be one of: blue, red, yellow, + green, cyan, purple, gray, or white. + ignore_uri_and_module_key_validation (bool): Whether to ignore the + validation of the module key and URI. For example, when a gadget is created + that is part of an application that is not installed. + module_key (str): The module to use in the gadget. Mutually exclusive with + `uri`. + position (dict[str, int]): A dictionary containing position information like - + `{"column": 0, "row", 1}`. + title (str): The title of the gadget. + uri (str): The uri to the module to use in the gadget. Mutually exclusive + with `module_key`. + + Returns: + DashboardGadget + """ + data = remove_empty_attributes( + { + "color": color, + "ignoreUriAndModuleKeyValidation": ignore_uri_and_module_key_validation, + "module_key": module_key, + "position": position, + "title": title, + "uri": uri, + } + ) + url = self._get_url(f"dashboard/{dashboard_id}/gadget") + r = self._session.post(url, json=data) + + raw_gadget_json: dict[str, Any] = json_loads(r) + return DashboardGadget(self._options, self._session, raw=raw_gadget_json) # Fields @@ -1392,11 +1718,13 @@ hasId = user.get("id") is not None and user.get("id") != "" hasName = user.get("name") is not None and user.get("name") != "" result[ - user["id"] - if hasId - else user.get("name") - if hasName - else user.get("accountId") + ( + user["id"] + if hasId + else user.get("name") + if hasName + else user.get("accountId") + ) ] = { "name": user.get("name"), "id": user.get("id"), @@ -1748,6 +2076,86 @@ "Use 'createmeta' instead." ) + def createmeta_issuetypes( + self, + projectIdOrKey: str | int, + startAt: int = 0, + maxResults: int = 50, + ) -> dict[str, Any]: + """Get the issue types metadata for a given project, required to create issues. + + .. deprecated:: 3.6.0 + Use :func:`project_issue_types` instead. + + This API was introduced in JIRA Server / DC 8.4 as a replacement for the more general purpose API 'createmeta'. + For details see: https://confluence.atlassian.com/jiracore/createmeta-rest-endpoint-to-be-removed-975040986.html + + Args: + projectIdOrKey (Union[str, int]): id or key of the project for which to get the metadata. + startAt (int): Index of the first issue to return. (Default: ``0``) + maxResults (int): Maximum number of issues to return. + Total number of results is available in the ``total`` attribute of the returned :class:`ResultList`. + If maxResults evaluates to False, it will try to get all issues in batches. (Default: ``50``) + + Returns: + Dict[str, Any] + """ + warnings.warn( + "'createmeta_issuetypes' is deprecated and will be removed in future releases." + "Use 'project_issue_types' instead.", + DeprecationWarning, + stacklevel=2, + ) + self._check_createmeta_issuetypes() + return self._get_json( + f"issue/createmeta/{projectIdOrKey}/issuetypes", + params={ + "startAt": startAt, + "maxResults": maxResults, + }, + ) + + def createmeta_fieldtypes( + self, + projectIdOrKey: str | int, + issueTypeId: str | int, + startAt: int = 0, + maxResults: int = 50, + ) -> dict[str, Any]: + """Get the field metadata for a given project and issue type, required to create issues. + + .. deprecated:: 3.6.0 + Use :func:`project_issue_fields` instead. + + This API was introduced in JIRA Server / DC 8.4 as a replacement for the more general purpose API 'createmeta'. + For details see: https://confluence.atlassian.com/jiracore/createmeta-rest-endpoint-to-be-removed-975040986.html + + Args: + projectIdOrKey (Union[str, int]): id or key of the project for which to get the metadata. + issueTypeId (Union[str, int]): id of the issue type for which to get the metadata. + startAt (int): Index of the first issue to return. (Default: ``0``) + maxResults (int): Maximum number of issues to return. + Total number of results is available in the ``total`` attribute of the returned :class:`ResultList`. + If maxResults evaluates to False, it will try to get all issues in batches. (Default: ``50``) + + Returns: + Dict[str, Any] + """ + warnings.warn( + "'createmeta_fieldtypes' is deprecated and will be removed in future releases." + "Use 'project_issue_fields' instead.", + DeprecationWarning, + stacklevel=2, + ) + self._check_createmeta_issuetypes() + return self._get_json( + f"issue/createmeta/{projectIdOrKey}/issuetypes/{issueTypeId}", + params={ + "startAt": startAt, + "maxResults": maxResults, + }, + ) + def createmeta( self, projectKeys: tuple[str, str] | str | None = None, @@ -1776,12 +2184,12 @@ if self._version >= (9, 0, 0): raise JIRAError( f"Unsupported JIRA version: {self._version}. " - "Use 'createmeta_issuetypes' and 'createmeta_fieldtypes' instead." + "Use 'project_issue_types' and 'project_issue_fields' instead." ) elif self._version >= (8, 4, 0): warnings.warn( "This API have been deprecated in JIRA 8.4 and is removed in JIRA 9.0. " - "Use 'createmeta_issuetypes' and 'createmeta_fieldtypes' instead.", + "Use 'project_issue_types' and 'project_issue_fields' instead.", DeprecationWarning, stacklevel=2, ) @@ -2377,15 +2785,15 @@ def add_worklog( self, issue: str | int, - timeSpent: (str | None) = None, - timeSpentSeconds: (str | None) = None, - adjustEstimate: (str | None) = None, - newEstimate: (str | None) = None, - reduceBy: (str | None) = None, - comment: (str | None) = None, - started: (datetime.datetime | None) = None, - user: (str | None) = None, - visibility: (dict[str, Any] | None) = None, + timeSpent: str | None = None, + timeSpentSeconds: str | None = None, + adjustEstimate: str | None = None, + newEstimate: str | None = None, + reduceBy: str | None = None, + comment: str | None = None, + started: datetime.datetime | None = None, + user: str | None = None, + visibility: dict[str, Any] | None = None, ) -> Worklog: """Add a new worklog entry on an issue and return a Resource for it. @@ -3875,6 +4283,24 @@ data = {"id": avatar} return self._session.put(url, params=params, data=json.dumps(data)) + def _get_internal_url(self, path: str, base: str = JIRA_BASE_URL) -> str: + """Returns the full internal api url based on Jira base url and the path provided. + + Using the API version specified during the __init__. + + Args: + path (str): The subpath desired. + base (Optional[str]): The base url which should be prepended to the path + + Returns: + str: Fully qualified URL + """ + options = self._options.copy() + options.update( + {"path": path, "rest_api_version": "latest", "rest_path": "internal"} + ) + return base.format(**options) + def _get_url(self, path: str, base: str = JIRA_BASE_URL) -> str: """Returns the full url based on Jira base url and the path provided. @@ -3941,7 +4367,7 @@ def _find_for_resource( self, resource_cls: Any, - ids: tuple[str, str] | tuple[str | int, str] | int | str, + ids: tuple[str, ...] | tuple[str | int, str] | int | str, expand=None, ) -> Any: """Uses the find method of the provided Resource class. @@ -4896,6 +5322,7 @@ startDate: Any | None = None, endDate: Any | None = None, state: str | None = None, + goal: str | None = None, ) -> dict[str, Any]: """Updates the sprint with the given values. @@ -4904,7 +5331,8 @@ name (Optional[str]): The name to update your sprint to startDate (Optional[Any]): The start date for the sprint endDate (Optional[Any]): The start date for the sprint - state: (Optional[str]): The start date for the sprint + state: (Optional[str]): The state of the sprint + goal: (Optional[str]): The goal of the sprint Returns: Dict[str, Any] @@ -4918,6 +5346,8 @@ payload["endDate"] = endDate if state: payload["state"] = state + if goal: + payload["goal"] = goal url = self._get_url(f"sprint/{id}", base=self.AGILE_BASE_URL) r = self._session.put(url, data=json.dumps(payload)) @@ -5047,6 +5477,7 @@ board_id: int, startDate: Any | None = None, endDate: Any | None = None, + goal: str | None = None, ) -> Sprint: """Create a new sprint for the ``board_id``. @@ -5055,6 +5486,7 @@ board_id (int): Which board the sprint should be assigned. startDate (Optional[Any]): Start date for the sprint. endDate (Optional[Any]): End date for the sprint. + goal (Optional[str]): Goal for the sprint. Returns: Sprint: The newly created Sprint @@ -5064,14 +5496,16 @@ payload["startDate"] = startDate if endDate: payload["endDate"] = endDate + if goal: + payload["goal"] = goal - raw_issue_json: dict[str, Any] + raw_sprint_json: dict[str, Any] url = self._get_url("sprint", base=self.AGILE_BASE_URL) payload["originBoardId"] = board_id r = self._session.post(url, data=json.dumps(payload)) - raw_issue_json = json_loads(r) + raw_sprint_json = json_loads(r) - return Sprint(self._options, self._session, raw=raw_issue_json) + return Sprint(self._options, self._session, raw=raw_sprint_json) def add_issues_to_sprint(self, sprint_id: int, issue_keys: list[str]) -> Response: """Add the issues in ``issue_keys`` to the ``sprint_id``. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/jira/exceptions.py new/jira-3.8.0/jira/exceptions.py --- old/jira-3.6.0/jira/exceptions.py 2024-01-05 18:17:52.000000000 +0100 +++ new/jira-3.8.0/jira/exceptions.py 2024-03-25 13:16:36.000000000 +0100 @@ -2,6 +2,7 @@ import os import tempfile +from typing import Any from requests import Response @@ -69,3 +70,14 @@ t += f"\n\t{details}" return t + + +class NotJIRAInstanceError(Exception): + """Raised in the case an object is not a JIRA instance.""" + + def __init__(self, instance: Any): + msg = ( + "The first argument of this function must be an instance of type " + f"JIRA. Instance Type: {instance.__class__.__name__}" + ) + super().__init__(msg) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/jira/resilientsession.py new/jira-3.8.0/jira/resilientsession.py --- old/jira-3.6.0/jira/resilientsession.py 2024-01-05 18:17:52.000000000 +0100 +++ new/jira-3.8.0/jira/resilientsession.py 2024-03-25 13:16:36.000000000 +0100 @@ -315,7 +315,9 @@ if response.status_code in recoverable_error_codes: retry_after = response.headers.get("Retry-After") if retry_after: - suggested_delay = int(retry_after) # Do as told + suggested_delay = 2 * max( + int(retry_after), 1 + ) # Do as told but always wait at least a little elif response.status_code == HTTPStatus.TOO_MANY_REQUESTS: suggested_delay = 10 * 2**counter # Exponential backoff @@ -326,7 +328,9 @@ is_recoverable = suggested_delay > 0 if is_recoverable: # Apply jitter to prevent thundering herd - delay = min(self.max_retry_delay, suggested_delay) * random.random() + delay = min(self.max_retry_delay, suggested_delay) * random.uniform( + 0.5, 1.0 + ) LOG.warning( f"Got recoverable error from {request_method} {url}, will retry [{counter}/{self.max_retries}] in {delay}s. Err: {msg}" # type: ignore[str-bytes-safe] ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/jira/resources.py new/jira-3.8.0/jira/resources.py --- old/jira-3.6.0/jira/resources.py 2024-01-05 18:17:52.000000000 +0100 +++ new/jira-3.8.0/jira/resources.py 2024-03-25 13:16:36.000000000 +0100 @@ -3,6 +3,7 @@ This module implements the Resource classes that translate JSON from Jira REST resources into usable objects. """ + from __future__ import annotations import json @@ -15,7 +16,7 @@ from requests.structures import CaseInsensitiveDict from jira.resilientsession import ResilientSession, parse_errors -from jira.utils import json_loads, threaded_requests +from jira.utils import json_loads, remove_empty_attributes, threaded_requests if TYPE_CHECKING: from jira.client import JIRA @@ -37,7 +38,10 @@ "Attachment", "Component", "Dashboard", + "DashboardItemProperty", + "DashboardItemPropertyKey", "Filter", + "DashboardGadget", "Votes", "PermissionScheme", "Watchers", @@ -239,7 +243,7 @@ def find( self, - id: tuple[str, str] | int | str, + id: tuple[str, ...] | int | str, params: dict[str, str] | None = None, ): """Finds a resource based on the input parameters. @@ -552,8 +556,157 @@ Resource.__init__(self, "dashboard/{0}", options, session) if raw: self._parse_raw(raw) + self.gadgets: list[DashboardGadget] = [] + self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + + +class DashboardItemPropertyKey(Resource): + """A jira dashboard item property key.""" + + def __init__( + self, + options: dict[str, str], + session: ResilientSession, + raw: dict[str, Any] = None, + ): + 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) + + +class DashboardItemProperty(Resource): + """A jira dashboard item.""" + + def __init__( + self, + options: dict[str, str], + session: ResilientSession, + raw: dict[str, Any] = None, + ): + Resource.__init__( + self, "dashboard/{0}/items/{1}/properties/{2}", options, session + ) + if raw: + self._parse_raw(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] + ) -> DashboardItemProperty: + """Update this resource on the server. + + Keyword arguments are marshalled into a dict before being sent. If this resource doesn't support ``PUT``, a :py:exc:`.JIRAError` + will be raised; subclasses that specialize this method will only raise errors in case of user error. + + Args: + dashboard_id (str): The ``id`` if the dashboard. + item_id (str): The id of the dashboard item (``DashboardGadget``) to target. + value (dict[str, Any]): The value of the targeted property key. + + Returns: + DashboardItemProperty + """ + options = self._options.copy() + options[ + "path" + ] = f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}" + self.raw["value"].update(value) + self._session.put(self.JIRA_BASE_URL.format(**options), self.raw["value"]) + + return DashboardItemProperty(self._options, self._session, raw=self.raw) + + def delete(self, dashboard_id: str, item_id: str) -> Response: # type: ignore[override] # incompatible supertype ignored + """Delete dashboard item property. + + Args: + dashboard_id (str): The ``id`` of the dashboard. + item_id (str): The ``id`` of the dashboard item (``DashboardGadget``). + + + Returns: + Response + """ + options = self._options.copy() + options[ + "path" + ] = f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}" + + return self._session.delete(self.JIRA_BASE_URL.format(**options)) + + +class DashboardGadget(Resource): + """A jira dashboard gadget.""" + + def __init__( + self, + options: dict[str, str], + session: ResilientSession, + raw: dict[str, Any] = None, + ): + Resource.__init__(self, "dashboard/{0}/gadget/{1}", options, session) + if raw: + self._parse_raw(raw) + self.item_properties: list[DashboardItemProperty] = [] self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + def update( # type: ignore[override] # incompatible supertype ignored + self, + dashboard_id: str, + color: str | None = None, + position: dict[str, Any] | None = None, + title: str | None = None, + ) -> DashboardGadget: + """Update this resource on the server. + + Keyword arguments are marshalled into a dict before being sent. If this resource doesn't support ``PUT``, a :py:exc:`.JIRAError` + will be raised; subclasses that specialize this method will only raise errors in case of user error. + + Args: + dashboard_id (str): The ``id`` of the dashboard to add the gadget to `required`. + color (str): The color of the gadget, should be one of: blue, red, yellow, + green, cyan, purple, gray, or white. + ignore_uri_and_module_key_validation (bool): Whether to ignore the + validation of the module key and URI. For example, when a gadget is created + that is part of an application that is not installed. + position (dict[str, int]): A dictionary containing position information like - + `{"column": 0, "row", 1}`. + title (str): The title of the gadget. + + Returns: + ``DashboardGadget`` + """ + data = remove_empty_attributes( + {"color": color, "position": position, "title": title} + ) + options = self._options.copy() + options["path"] = f"dashboard/{dashboard_id}/gadget/{self.id}" + + self._session.put(self.JIRA_BASE_URL.format(**options), json=data) + options["path"] = f"dashboard/{dashboard_id}/gadget" + + return next( + DashboardGadget(self._options, self._session, raw=gadget) + for gadget in self._session.get( + self.JIRA_BASE_URL.format(**options) + ).json()["gadgets"] + if gadget["id"] == self.id + ) + + def delete(self, dashboard_id: str) -> Response: # type: ignore[override] # incompatible supertype ignored + """Delete gadget from dashboard. + + Args: + dashboard_id (str): The ``id`` of the dashboard. + + Returns: + Response + """ + options = self._options.copy() + options["path"] = f"dashboard/{dashboard_id}/gadget/{self.id}" + + return self._session.delete(self.JIRA_BASE_URL.format(**options)) + class Field(Resource): """An issue field. @@ -1492,6 +1645,9 @@ r"component/[^/]+$": Component, r"customFieldOption/[^/]+$": CustomFieldOption, r"dashboard/[^/]+$": Dashboard, + r"dashboard/[^/]+/items/[^/]+/properties+$": DashboardItemPropertyKey, + r"dashboard/[^/]+/items/[^/]+/properties/[^/]+$": DashboardItemProperty, + r"dashboard/[^/]+/gadget/[^/]+$": DashboardGadget, r"filter/[^/]$": Filter, r"issue/[^/]+$": Issue, r"issue/[^/]+/comment/[^/]+$": Comment, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/jira/utils/__init__.py new/jira-3.8.0/jira/utils/__init__.py --- old/jira-3.6.0/jira/utils/__init__.py 2024-01-05 18:17:52.000000000 +0100 +++ new/jira-3.8.0/jira/utils/__init__.py 2024-03-25 13:16:36.000000000 +0100 @@ -1,4 +1,5 @@ """Jira utils used internally.""" + from __future__ import annotations import threading @@ -79,3 +80,15 @@ if not resp.text: return {} raise + + +def remove_empty_attributes(data: dict[str, Any]) -> dict[str, Any]: + """A convenience function to remove key/value pairs with `None` for a value. + + Args: + data: A dictionary. + + Returns: + Dict[str, Any]: A dictionary with no `None` key/value pairs. + """ + return {key: val for key, val in data.items() if val is not None} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/jira.egg-info/PKG-INFO new/jira-3.8.0/jira.egg-info/PKG-INFO --- old/jira-3.6.0/jira.egg-info/PKG-INFO 2024-01-05 18:18:04.000000000 +0100 +++ new/jira-3.8.0/jira.egg-info/PKG-INFO 2024-03-25 13:16:45.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: jira -Version: 3.6.0 +Version: 3.8.0 Summary: Python library for interacting with JIRA via REST APIs. Home-page: https://github.com/pycontribs/jira Author: Ben Speakmon diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/jira-3.6.0/jira.egg-info/SOURCES.txt new/jira-3.8.0/jira.egg-info/SOURCES.txt --- old/jira-3.6.0/jira.egg-info/SOURCES.txt 2024-01-05 18:18:04.000000000 +0100 +++ new/jira-3.8.0/jira.egg-info/SOURCES.txt 2024-03-25 13:16:45.000000000 +0100 @@ -8,6 +8,7 @@ LICENSE MANIFEST.in README.rst +RELEASE.md codecov.yml constraints.txt make_local_jira_user.py
