From 90da8001f59767fb4357227fa577034853c42cc7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Feb 2024 23:29:07 +0100 Subject: [PATCH 01/14] Bump sphinxcontrib-serializinghtml from 1.1.9 to 1.1.10 (#1816) Bumps [sphinxcontrib-serializinghtml](https://github.com/sphinx-doc/sphinxcontrib-serializinghtml) from 1.1.9 to 1.1.10. - [Release notes](https://github.com/sphinx-doc/sphinxcontrib-serializinghtml/releases) - [Changelog](https://github.com/sphinx-doc/sphinxcontrib-serializinghtml/blob/master/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinxcontrib-serializinghtml/compare/1.1.9...1.1.10) --- updated-dependencies: - dependency-name: sphinxcontrib-serializinghtml dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constraints.txt b/constraints.txt index 98ceea1ae..f66ae1ec3 100644 --- a/constraints.txt +++ b/constraints.txt @@ -200,7 +200,7 @@ sphinxcontrib-jsmath==1.0.1 # 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 From a9793e08d363be11c8d802ea7445a95e2eae53b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Feb 2024 23:29:47 +0100 Subject: [PATCH 02/14] Bump jinja2 from 3.1.2 to 3.1.3 (#1815) Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.2 to 3.1.3. - [Release notes](https://github.com/pallets/jinja/releases) - [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/jinja/compare/3.1.2...3.1.3) --- updated-dependencies: - dependency-name: jinja2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constraints.txt b/constraints.txt index f66ae1ec3..f0df5b213 100644 --- a/constraints.txt +++ b/constraints.txt @@ -71,7 +71,7 @@ jaraco-classes==3.3.0 # 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) From ad17dc0dc63ed02932cae70fcf1cecc060a4e7bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Feb 2024 23:32:30 +0100 Subject: [PATCH 03/14] Bump sphinxcontrib-devhelp from 1.0.2 to 1.0.6 (#1811) Bumps [sphinxcontrib-devhelp](https://github.com/sphinx-doc/sphinxcontrib-devhelp) from 1.0.2 to 1.0.6. - [Release notes](https://github.com/sphinx-doc/sphinxcontrib-devhelp/releases) - [Changelog](https://github.com/sphinx-doc/sphinxcontrib-devhelp/blob/1.0.6/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinxcontrib-devhelp/compare/1.0.2...1.0.6) --- updated-dependencies: - dependency-name: sphinxcontrib-devhelp dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constraints.txt b/constraints.txt index f0df5b213..4f505cd9e 100644 --- a/constraints.txt +++ b/constraints.txt @@ -192,7 +192,7 @@ sphinx-copybutton==0.5.2 # 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 From ff6985b7a9efff6b7b72490a4f8c61c398152796 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Feb 2024 23:33:42 +0100 Subject: [PATCH 04/14] Bump beautifulsoup4 from 4.12.2 to 4.12.3 (#1808) Bumps [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/bs4/) from 4.12.2 to 4.12.3. --- updated-dependencies: - dependency-name: beautifulsoup4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constraints.txt b/constraints.txt index 4f505cd9e..3f1572ec0 100644 --- a/constraints.txt +++ b/constraints.txt @@ -12,7 +12,7 @@ babel==2.14.0 # via sphinx backcall==0.2.0 # via ipython -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via furo certifi==2023.11.17 # via requests From 4999a76de90278ad115df576c6940beeeb9249f8 Mon Sep 17 00:00:00 2001 From: Matthias Bach <64144070+matthias-bach-by@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:11:21 +0100 Subject: [PATCH 05/14] Improve handling of Jira's retry-after handling (#1825) * Respect the "Retry-After" times requested by Jira The time Jira sends in the Retry-After header is the minimum time Jira wants us to wait before retrying our request. However, the former implementation used this as a maximum waiting time for the next request. In result, there was a chance that we reached three retries without reaching the time that Jira expected us to wait and our request would fail. This implementation does also affect the other retry cases, as while previously we jittered our backoff between 0 and the target backoff, we now only jitter between 50% and 100% of the target backoff. However, this should still protect us from thundering herds and safes us from introducing a new minimum backoff variable for the retry-after case. * Also retry requests where Jira specifies a Retry-after of 0 seconds When rejecting request with a 429 response, Jira sometimes sends a Retry-after header asking for a backoff of 0 seconds. With the existing retry logic this would mark the request as non-retryable and thus fail the request. With this change, such requests are treated as if Jira had send a retry-after value of 1 second. --- jira/resilientsession.py | 8 ++++++-- tests/test_resilientsession.py | 20 +++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/jira/resilientsession.py b/jira/resilientsession.py index 6b7b11670..f5447f2fa 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -315,7 +315,9 @@ def __recoverable( 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 @@ def __recoverable( 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 --git a/tests/test_resilientsession.py b/tests/test_resilientsession.py index b5e22bb2c..4a7bbd800 100644 --- a/tests/test_resilientsession.py +++ b/tests/test_resilientsession.py @@ -65,13 +65,17 @@ def tearDown(self): # Retry test data tuples: (status_code, with_rate_limit_header, with_retry_after_header, retry_expected) -with_rate_limit = with_retry_after = True -without_rate_limit = without_retry_after = False +with_rate_limit = True +with_retry_after = 1 +without_rate_limit = False +without_retry_after = None status_codes_retries_test_data = [ # Always retry 429 responses (HTTPStatus.TOO_MANY_REQUESTS, with_rate_limit, with_retry_after, True), + (HTTPStatus.TOO_MANY_REQUESTS, with_rate_limit, 0, True), (HTTPStatus.TOO_MANY_REQUESTS, with_rate_limit, without_retry_after, True), (HTTPStatus.TOO_MANY_REQUESTS, without_rate_limit, with_retry_after, True), + (HTTPStatus.TOO_MANY_REQUESTS, without_rate_limit, 0, True), (HTTPStatus.TOO_MANY_REQUESTS, without_rate_limit, without_retry_after, True), # Retry 503 responses only when 'Retry-After' in headers (HTTPStatus.SERVICE_UNAVAILABLE, with_rate_limit, with_retry_after, True), @@ -103,10 +107,11 @@ def test_status_codes_retries( mocked_request_method: Mock, status_code: int, with_rate_limit_header: bool, - with_retry_after_header: bool, + with_retry_after_header: int | None, retry_expected: bool, ): - RETRY_AFTER_HEADER = {"Retry-After": "1"} + RETRY_AFTER_SECONDS = with_retry_after_header or 0 + RETRY_AFTER_HEADER = {"Retry-After": f"{RETRY_AFTER_SECONDS}"} RATE_LIMIT_HEADERS = { "X-RateLimit-FillRate": "1", "X-RateLimit-Interval-Seconds": "1", @@ -124,7 +129,7 @@ def test_status_codes_retries( mocked_response: Response = Response() mocked_response.status_code = status_code - if with_retry_after_header: + if with_retry_after_header is not None: mocked_response.headers.update(RETRY_AFTER_HEADER) if with_rate_limit_header: mocked_response.headers.update(RATE_LIMIT_HEADERS) @@ -141,6 +146,11 @@ def test_status_codes_retries( assert mocked_request_method.call_count == expected_number_of_requests assert mocked_sleep_method.call_count == expected_number_of_sleep_invocations + for actual_sleep in ( + call_args.args[0] for call_args in mocked_sleep_method.call_args_list + ): + assert actual_sleep >= RETRY_AFTER_SECONDS + errors_parsing_test_data = [ (403, {"x-authentication-denied-reason": "err1"}, "", ["err1"]), From fbcabe182833236eb4456f1dd006602cbd4f156a Mon Sep 17 00:00:00 2001 From: Justin Palmer Date: Fri, 22 Mar 2024 05:50:51 -0500 Subject: [PATCH 06/14] Add `Dashboard` Support (#1836, #1837) `jira/client.py` ---------------- * Added `cloud_api` convenience decorator for client methods that make calls that are only available on the `cloud_api` api. It checks the `client` instance to see if it `_is_cloud`. If not, it logs a warning and returns `None`. This was the convention seen on other endpoints on the `client`. * Added `experimental_atlassian_api` convenience decorator for client methods that make calls that are experimental. It attempts to run the client method, if a `JIRAError` is raised that has a response object, the response is checked for a status code in `[404, 405]` indicating either the path no longer accepts the HTTP verb or no longer exists, and then logs a warning and returns `None`. Otherwise it re-raises the error * Imported `DashboardItemProperty`, `DashboardItemPropertyKey`, and `DashboardGadget` resources to client for use in new methods. * Updated the `dashboards` method to include the `gadgets` that exist on a given dashboard. This is a logical association that makes sense, but isn't directly exposed in the API. * Added `create_dashboard` method. It creates a dashboard via the API and returns a `Dashboard` object. * Added `copy_dashboard` method. * Added `update_dashboard_automatic_refresh_seconds` method. This calls the `internal` API, which is why it's decorated with `experimental_atlassian_api` and `cloud_api`. This might change in the future, but it really is a handy thing to have, otherwise, the user has to configure this in the web interface. --- * Added `dashboard_item_property` method. This is available on both `cloud_api` and `server`. * Added `dashboard_item_property_keys` method. This is available on both `cloud_api` and `server`. * Added `set_dashboard_item_property` method. This is available on both `cloud_api` and `server`. --- ^^ These methods all provide a means of adding arbitrary metadata to `dashboard_items` (`gadgets`) and/or configure them via specific keys. * Added `dashboard_gadgets` method. This returns the gadgets associated with a given dashboard. It also iterates over the `keys` for this `gadget`'s properties, generating a list of `DashboardItemProperty` objects that are associated with each gadget. This makes it really easy for the user to associate which configuration/metadata goes with which gadget. * Added `all_dashboard_gadgets` method. This returns a list of from `jira` of all the `gadgets` that are available to add to any dashboard. * Added `add_gadget_to_dashboard` method. This allows the user to add gadgets to a specified dashboard. * Added the protected method `_get_internal_url`. This is very similar to `get_url` or `get_latest` url, where `options` are updated to allow for easy resolution of paths that are on the `internal` `jira` api. * Updated the `_find_for_resource` typehint on `ids` because it is possible that a resource requires more than `2` ids to resolve it's url. jira/resources.py ----------------- * Added the new resources `DashboardItemProperty`, `DashboardItemPropertyKey`, and `Gadget` to the `__all__` list so they are represented. * Added a `gadgets` attribute to the `Dashboard` resource to house `gadget` references. * Added `DashboardItemPropertyKey` resource. * Added `DashboardItemProperty` resource. The `update` and `delete` methods are overridden here because it does not have a `self` attribute. This is kind of in an in between space as far as being considered a resource, but for ease of use as an interface, it makes sense for it to be considered. * Added `DashboardGadget` resource. It too has overridden `update` and `delete` methods for the aforementioned reasons. jira/utils/__init__.py ---------------------- * Added `remove_empty_attributes` convenience method. I found myself having to remove empty attributes or add a lot of branching in order to accommodate optional payload parameters or path parameters. This function made that easier. jira/utils/exceptions.py ------------------------ * Created `NotJIRAInstanceError` exception. This is raised in the case one of the convenience decorators utilized on a client method is improperly applied to some other kind of object. --- jira/client.py | 384 +++++++++++++++++++++-- jira/exceptions.py | 12 + jira/resources.py | 160 +++++++++- jira/utils/__init__.py | 13 + tests/conftest.py | 4 + tests/resources/test_dashboard.py | 367 +++++++++++++++++++++- tests/resources/test_generic_resource.py | 8 + tests/test_client.py | 144 ++++++++- 8 files changed, 1069 insertions(+), 23 deletions(-) diff --git a/jira/client.py b/jira/client.py index 1c8534869..892c12416 100644 --- a/jira/client.py +++ b/jira/client.py @@ -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 @@ def dashboards( 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 @@ def dashboard(self, id: str) -> Dashboard: 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 @@ def group_members(self, group: str) -> OrderedDict: 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"), @@ -2377,15 +2705,15 @@ def worklog(self, issue: str | int, id: str) -> Worklog: 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 +4203,24 @@ def _set_avatar(self, params, url, avatar): 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 +4287,7 @@ def _get_json( 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. diff --git a/jira/exceptions.py b/jira/exceptions.py index 027ce14ba..0047133e5 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -2,6 +2,7 @@ import os import tempfile +from typing import Any from requests import Response @@ -69,3 +70,14 @@ def __str__(self) -> str: 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 --git a/jira/resources.py b/jira/resources.py index 57ec31bbc..0f48aa882 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -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 @@ class AnyLike: "Attachment", "Component", "Dashboard", + "DashboardItemProperty", + "DashboardItemPropertyKey", "Filter", + "DashboardGadget", "Votes", "PermissionScheme", "Watchers", @@ -239,7 +243,7 @@ def __eq__(self, other: Any) -> bool: 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 @@ def __init__( 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 @@ def dict2resource( 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 --git a/jira/utils/__init__.py b/jira/utils/__init__.py index c8945f3e4..f512ed44a 100644 --- a/jira/utils/__init__.py +++ b/jira/utils/__init__.py @@ -1,4 +1,5 @@ """Jira utils used internally.""" + from __future__ import annotations import threading @@ -79,3 +80,15 @@ def json_loads(resp: Response | None) -> Any: 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 --git a/tests/conftest.py b/tests/conftest.py index 140cb07d9..38f08ef0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,10 @@ allow_on_cloud = pytest.mark.allow_on_cloud +only_run_on_cloud = pytest.mark.skipif( + os.environ.get("CI_JIRA_TYPE", "Server").upper() != "CLOUD", + reason="Functionality only available on Jira Cloud", +) broken_test = pytest.mark.xfail diff --git a/tests/resources/test_dashboard.py b/tests/resources/test_dashboard.py index e2327bfd5..a282f3538 100644 --- a/tests/resources/test_dashboard.py +++ b/tests/resources/test_dashboard.py @@ -1,9 +1,43 @@ from __future__ import annotations -from tests.conftest import JiraTestCase, broken_test +from unittest import mock + +import pytest + +from jira.exceptions import JIRAError +from jira.resources import ( + Dashboard, + DashboardGadget, + DashboardItemProperty, + DashboardItemPropertyKey, +) +from tests.conftest import ( + JiraTestCase, + allow_on_cloud, + broken_test, + only_run_on_cloud, + rndstr, +) class DashboardTests(JiraTestCase): + def setUp(self): + super().setUp() + self.dashboards_to_delete = [] + self.gadget_title = "Filter Results" + self.dashboard_item_expected_key = "config" + self.dashboard_item_column_names = "issuetype|issuekey|summary|priority|status" + self.dashboard_item_num = 5 + self.dashboard_item_refresh = 15 + self.filter = self.jira.create_filter( + rndstr(), "description", f"project={self.project_b}", True + ) + + def tearDown(self): + for dashboard in self.dashboards_to_delete: + dashboard.delete() + super().tearDown() + def test_dashboards(self): dashboards = self.jira.dashboards() self.assertGreaterEqual(len(dashboards), 1) @@ -29,3 +63,334 @@ def test_dashboard(self): dashboard = self.jira.dashboard(expected_ds.id) self.assertEqual(dashboard.id, expected_ds.id) self.assertEqual(dashboard.name, expected_ds.name) + + @only_run_on_cloud + @allow_on_cloud + def test_create_dashboard(self): + name = rndstr() + description = rndstr() + share_permissions = [{"type": "authenticated"}] + + dashboard = self.jira.create_dashboard( + name=name, description=description, share_permissions=share_permissions + ) + self.assertIsInstance(dashboard, Dashboard) + self.dashboards_to_delete.append(dashboard) + + self.assertEqual(dashboard.name, name) + self.assertEqual(dashboard.description, description) + # This is a bit obtuse, but Jira mutates the type on this + # object after the fact. `authenticated` corresponds to `loggedin`. + self.assertEqual(dashboard.sharePermissions[0].type, "loggedin") + + # The system dashboard always has the ID `10000`, just + # ensuring we actually have a + self.assertGreater(int(dashboard.id), 10000) + + @only_run_on_cloud + @allow_on_cloud + def test_update_dashboard(self): + updated_name = "changed" + name = rndstr() + description = rndstr() + share_permissions = [{"type": "authenticated"}] + + dashboard = self.jira.create_dashboard( + name=name, description=description, share_permissions=share_permissions + ) + self.assertIsInstance(dashboard, Dashboard) + self.dashboards_to_delete.append(dashboard) + + dashboard.update(name=updated_name) + self.assertEqual(dashboard.name, updated_name) + + @only_run_on_cloud + @allow_on_cloud + def test_delete_dashboard(self): + dashboard = self.jira.create_dashboard(name="to_delete") + dashboard_id = dashboard.id + delete_response = dashboard.delete() + self.assertEqual(delete_response.status_code, 204) + + with pytest.raises(JIRAError) as ex: + self.jira.dashboard(dashboard_id) + + self.assertEqual(ex.value.status_code, 404) + self.assertEqual( + ex.value.text, f"The dashboard with id '{dashboard_id}' does not exist." + ) + + @only_run_on_cloud + @allow_on_cloud + def test_copy_dashboard(self): + original_dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(original_dashboard) + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + + original_gadget = self.jira.add_gadget_to_dashboard( + original_dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + original_dashboard = self.jira.dashboard(original_dashboard.id) + + copied_dashboard = self.jira.copy_dashboard( + original_dashboard.id, name=rndstr() + ) + copied_dashboard = self.jira.dashboard(copied_dashboard.id) + self.assertIsInstance(copied_dashboard, Dashboard) + self.dashboards_to_delete.append(copied_dashboard) + + self.assertEqual(len(original_dashboard.gadgets), len(copied_dashboard.gadgets)) + self.assertEqual(original_gadget.color, copied_dashboard.gadgets[0].color) + self.assertEqual(original_gadget.uri, copied_dashboard.gadgets[0].uri) + + @only_run_on_cloud + @allow_on_cloud + def test_all_dashboard_gadgets(self): + # This is a super basic test. We can't really rely on the fact + # that the gadgets available at any given moment will be specifically represented + # here and it would be silly to have to update the tests to adjust for that if + # the starting list ever changed. + gadgets = self.jira.all_dashboard_gadgets() + self.assertGreater(len(gadgets), 0) + self.assertIsInstance(gadgets[0], DashboardGadget) + + @only_run_on_cloud + @allow_on_cloud + def test_dashboard_gadgets(self): + gadget_count = 3 + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + for _ in range(0, gadget_count): + self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + dashboard_gadgets = self.jira.dashboard_gadgets(dashboard.id) + self.assertEqual(len(dashboard_gadgets), gadget_count) + + for dashboard_gadget in dashboard_gadgets: + self.assertIsInstance(dashboard_gadget, DashboardGadget) + + @only_run_on_cloud + @allow_on_cloud + def test_update_dashboard_automatic_refresh_minutes(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + response = self.jira.update_dashboard_automatic_refresh_minutes( + dashboard.id, 10 + ) + self.assertEqual(response.status_code, 204) + response = self.jira.update_dashboard_automatic_refresh_minutes(dashboard.id, 0) + self.assertEqual(response.status_code, 204) + + @only_run_on_cloud + @allow_on_cloud + def test_add_gadget_to_dashboard(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + dashboard = self.jira.dashboard(dashboard.id) + self.assertEqual(dashboard.gadgets[0], gadget) + self.assertIsInstance(dashboard.gadgets[0], DashboardGadget) + + @only_run_on_cloud + @allow_on_cloud + def test_remove_gadget_from_dashboard(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + dashboard = self.jira.dashboard(dashboard.id) + self.assertEqual(len(dashboard.gadgets), 1) + self.assertEqual(dashboard.gadgets[0], gadget) + + gadget.delete(dashboard.id) + dashboard = self.jira.dashboard(dashboard.id) + self.assertEqual(len(dashboard.gadgets), 0) + + @only_run_on_cloud + @allow_on_cloud + def test_update_gadget(self): + new_color = "green" + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + gadget = gadget.update(dashboard.id, color=new_color) + self.assertEqual(gadget.color, new_color) + self.assertEqual(gadget.raw["color"], new_color) + self.assertIsInstance(gadget, DashboardGadget) + + @only_run_on_cloud + @allow_on_cloud + def test_dashboard_item_property_keys(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + dashboard_item_property_keys = self.jira.dashboard_item_property_keys( + dashboard.id, gadget.id + ) + self.assertEqual(len(dashboard_item_property_keys), 0) + + item_property_payload = { + "filterId": self.filter.id, + "columnNames": self.dashboard_item_column_names, + "num": self.dashboard_item_num, + "refresh": self.dashboard_item_refresh, + } + self.jira.set_dashboard_item_property( + dashboard.id, + gadget.id, + self.dashboard_item_expected_key, + value=item_property_payload, + ) + + dashboard_item_property_keys = self.jira.dashboard_item_property_keys( + dashboard.id, gadget.id + ) + self.assertEqual(len(dashboard_item_property_keys), 1) + self.assertEqual( + dashboard_item_property_keys[0].key, self.dashboard_item_expected_key + ) + self.assertIsInstance(dashboard_item_property_keys[0], DashboardItemPropertyKey) + + delete_response = dashboard_item_property_keys[0].delete() + self.assertEqual(delete_response.status_code, 204) + + dashboard_item_property_keys = self.jira.dashboard_item_property_keys( + dashboard.id, gadget.id + ) + self.assertEqual(len(dashboard_item_property_keys), 0) + + @only_run_on_cloud + @allow_on_cloud + def test_dashboard_item_properties(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + item_property_payload = { + "filterId": self.filter.id, + "columnNames": self.dashboard_item_column_names, + "num": self.dashboard_item_num, + "refresh": self.dashboard_item_refresh, + } + dashboard_item_property = self.jira.set_dashboard_item_property( + dashboard.id, + gadget.id, + self.dashboard_item_expected_key, + value=item_property_payload, + ) + + dashboard = self.jira.dashboard(dashboard.id) + self.assertEqual( + dashboard.gadgets[0].item_properties[0], dashboard_item_property + ) + self.assertIsInstance(dashboard_item_property, DashboardItemProperty) + + updated_item_property_payload = {"num": 10} + updated_dashboard_item_property = dashboard_item_property.update( + dashboard.id, gadget.id, value=updated_item_property_payload + ) + self.assertEqual( + updated_dashboard_item_property.value.num, + updated_item_property_payload["num"], + ) + + delete_response = updated_dashboard_item_property.delete( + dashboard.id, gadget.id + ) + self.assertEqual(delete_response.status_code, 204) + + @only_run_on_cloud + @allow_on_cloud + @mock.patch("requests.Session.request") + def test_set_dashboard_item_property_not_201_response(self, mocked_request): + mocked_request.return_value = mock.MagicMock(ok=False, status_code=404) + with pytest.raises(JIRAError) as ex: + self.jira.set_dashboard_item_property( + "id", "item_id", "config", {"this": "that"} + ) + + assert ex.value.status_code == 404 diff --git a/tests/resources/test_generic_resource.py b/tests/resources/test_generic_resource.py index a7a2d3e9f..383e61eb1 100644 --- a/tests/resources/test_generic_resource.py +++ b/tests/resources/test_generic_resource.py @@ -23,6 +23,10 @@ class TestResource: (url_test_case("group?groupname=bla"), jira.resources.Group), (url_test_case("user?username=bla"), jira.resources.User), # Jira Server / Data Center (url_test_case("user?accountId=bla"), jira.resources.User), # Jira Cloud + (url_test_case("api/latest/dashboard/12345"), jira.resources.Dashboard), + (url_test_case("api/latest/dashboard/1/items/1/properties"), jira.resources.DashboardItemPropertyKey), + (url_test_case("api/latest/dashboard/1/items/1/properties/property"), jira.resources.DashboardItemProperty), + (url_test_case("api/latest/dashboard/1/gadget/1"), jira.resources.DashboardGadget) ], ids=[ "issue", @@ -32,6 +36,10 @@ class TestResource: "group", "user", "user_cloud", + "dashboard", + "dashboard_item_property_key", + "dashboard_item_property", + "dashboard_gadget" ], ) # fmt: on diff --git a/tests/test_client.py b/tests/test_client.py index ddfb278fe..e9bf534f2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,13 +1,14 @@ from __future__ import annotations import getpass +import logging from unittest import mock import pytest import requests.sessions import jira.client -from jira.exceptions import JIRAError +from jira.exceptions import JIRAError, NotJIRAInstanceError from tests.conftest import JiraTestManager, get_unique_project_name @@ -51,6 +52,75 @@ def slug(request: pytest.FixtureRequest, cl_admin: jira.client.JIRA): pass +@pytest.fixture(scope="session") +def stream_logger(): + logger = logging.getLogger("test_logger") + logger.addHandler(logging.StreamHandler()) + return logger + + +@pytest.fixture(scope="session") +def mock_not_jira_client(stream_logger): + class MockClient: + def __init__(self, is_cloud=False): + self.is_cloud = is_cloud + self.log = stream_logger + + @property + def _is_cloud(self): + return self.is_cloud + + @jira.client.cloud_api + def mock_cloud_only_method(self, *args, **kwargs): + return args, kwargs + + @jira.client.experimental_atlassian_api + def mock_experimental_method(self, *args, **kwargs): + return args, kwargs + + @jira.client.experimental_atlassian_api + def mock_method_raises_jira_error(self, *args, **kwargs): + raise JIRAError(**kwargs) + + return MockClient + + +@pytest.fixture(scope="session") +def mock_jira_client(stream_logger): + class MockClient(jira.client.JIRA): + def __init__(self, is_cloud=False): + self.is_cloud = is_cloud + self.log = stream_logger + + @property + def _is_cloud(self): + return self.is_cloud + + @jira.client.cloud_api + def mock_cloud_only_method(self, *args, **kwargs): + return args, kwargs + + @jira.client.experimental_atlassian_api + def mock_experimental_method(self, *args, **kwargs): + return args, kwargs + + @jira.client.experimental_atlassian_api + def mock_method_raises_jira_error(self, *args, **kwargs): + raise JIRAError(**kwargs) + + return MockClient + + +@pytest.fixture(scope="session") +def mock_response(): + class MockResponse: + def __init__(self, status_code=404): + self.status_code = status_code + self.url = "some/url/that/does/not/exist" + + return MockResponse + + def test_delete_project(cl_admin, cl_normal, slug): assert cl_admin.delete_project(slug) @@ -246,3 +316,75 @@ def test_cookie_auth_retry(): ) # THEN: We don't get a RecursionError and only call the reset_function once mock_reset_func.assert_called_once() + + +@pytest.mark.parametrize( + "mock_client_method", ["mock_cloud_only_method", "mock_experimental_method"] +) +def test_not_cloud_instance(mock_not_jira_client, mock_client_method): + client = mock_not_jira_client() + with pytest.raises(NotJIRAInstanceError) as exc: + getattr(client, mock_client_method)() + + assert str(exc.value) == ( + "The first argument of this function must be an instance of type " + f"JIRA. Instance Type: {mock_not_jira_client().__class__.__name__}" + ) + + +@mock.patch("requests.Session.request") +def test_cloud_api(mock_request, mock_jira_client): + mock_client = mock_jira_client(is_cloud=True) + out = mock_client.mock_cloud_only_method("one", two="three") + assert out is not None + + +@mock.patch("requests.Session.request") +def test_cloud_api_not_cloud_server(mock_request, mock_jira_client, caplog): + mock_client = mock_jira_client() + mock_client.mock_cloud_only_method() + assert caplog.messages[0] == ( + "This functionality is not available on Jira Data Center (Server) version." + ) + + +@mock.patch("requests.Session.request") +def test_experimental(mock_request, mock_jira_client): + out = mock_jira_client().mock_experimental_method("one", two="three") + assert out is not None + + +@pytest.mark.parametrize("http_status_code", [404, 405]) +@mock.patch("requests.Session.request") +def test_experimental_missing_or_not_allowed( + mock_request, mock_jira_client, mock_response, http_status_code, caplog +): + mock_response = mock_response(status_code=http_status_code) + response = mock_jira_client().mock_method_raises_jira_error( + response=mock_response, + request=mock_response, + status_code=mock_response.status_code, + ) + assert response is None + assert caplog.messages[0] == ( + f"Functionality at path {mock_response.url} is/was experimental. Status Code: " + f"{mock_response.status_code}" + ) + + +@mock.patch("requests.Session.request") +def test_experimental_non_200_not_404_405( + mock_request, mock_jira_client, mock_response +): + status_code = 400 + mock_response = mock_response(status_code=status_code) + + with pytest.raises(JIRAError) as ex: + mock_jira_client().mock_method_raises_jira_error( + response=mock_response, + request=mock_response, + status_code=mock_response.status_code, + ) + + assert ex.value.status_code == status_code + assert isinstance(ex.value, JIRAError) From e8104eaaa6284027c3cd9365153ef40b0872c0cf Mon Sep 17 00:00:00 2001 From: Alex L Date: Fri, 22 Mar 2024 03:54:49 -0700 Subject: [PATCH 07/14] Update createmeta warning with new method names (#1820) --- jira/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jira/client.py b/jira/client.py index 892c12416..6bbd318d8 100644 --- a/jira/client.py +++ b/jira/client.py @@ -2104,12 +2104,12 @@ def createmeta( 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, ) From 4960d8fef6ce6c4c4c75bf007fcc8c909e466045 Mon Sep 17 00:00:00 2001 From: Parvizsho Aminov Date: Mon, 25 Mar 2024 04:35:42 -0700 Subject: [PATCH 08/14] add backward compatibility for createmeta_issuetypes & createmeta_fieldtypes (#1838) --- jira/client.py | 80 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_client.py | 19 +++++++++++ 2 files changed, 99 insertions(+) diff --git a/jira/client.py b/jira/client.py index 6bbd318d8..3fe01faf6 100644 --- a/jira/client.py +++ b/jira/client.py @@ -2076,6 +2076,86 @@ def _check_createmeta_issuetypes(self) -> None: "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, diff --git a/tests/test_client.py b/tests/test_client.py index e9bf534f2..10d633094 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -318,6 +318,25 @@ def test_cookie_auth_retry(): mock_reset_func.assert_called_once() +def test_createmeta_issuetypes_pagination(cl_normal, slug): + """Test createmeta_issuetypes pagination kwargs""" + issue_types_resp = cl_normal.createmeta_issuetypes(slug, startAt=50, maxResults=100) + assert issue_types_resp["startAt"] == 50 + assert issue_types_resp["maxResults"] == 100 + + +def test_createmeta_fieldtypes_pagination(cl_normal, slug): + """Test createmeta_fieldtypes pagination kwargs""" + issue_types = cl_normal.createmeta_issuetypes(slug) + assert issue_types["total"] + issue_type_id = issue_types["values"][-1]["id"] + field_types_resp = cl_normal.createmeta_fieldtypes( + projectIdOrKey=slug, issueTypeId=issue_type_id, startAt=50, maxResults=100 + ) + assert field_types_resp["startAt"] == 50 + assert field_types_resp["maxResults"] == 100 + + @pytest.mark.parametrize( "mock_client_method", ["mock_cloud_only_method", "mock_experimental_method"] ) From fbdb2bfa9f33b4607c295de9694194c23aa9a8bb Mon Sep 17 00:00:00 2001 From: Zach Barahal Date: Mon, 25 Mar 2024 04:46:59 -0700 Subject: [PATCH 09/14] Add `goal` field to update/create sprint (#1806) --- jira/client.py | 16 ++++++++++++---- tests/resources/test_sprint.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/jira/client.py b/jira/client.py index 3fe01faf6..0c456a3f9 100644 --- a/jira/client.py +++ b/jira/client.py @@ -5322,6 +5322,7 @@ def update_sprint( 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. @@ -5330,7 +5331,8 @@ def update_sprint( 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] @@ -5344,6 +5346,8 @@ def update_sprint( 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)) @@ -5473,6 +5477,7 @@ def create_sprint( board_id: int, startDate: Any | None = None, endDate: Any | None = None, + goal: str | None = None, ) -> Sprint: """Create a new sprint for the ``board_id``. @@ -5481,6 +5486,7 @@ def create_sprint( 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 @@ -5490,14 +5496,16 @@ def create_sprint( 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 --git a/tests/resources/test_sprint.py b/tests/resources/test_sprint.py index 81e86fdcc..83c0d43b1 100644 --- a/tests/resources/test_sprint.py +++ b/tests/resources/test_sprint.py @@ -22,6 +22,7 @@ def setUp(self): self.board_name = f"board-{uniq}" self.sprint_name = f"sprint-{uniq}" self.filter_name = f"filter-{uniq}" + self.sprint_goal = f"goal-{uniq}" self.board, self.filter = self._create_board_and_filter() @@ -76,6 +77,36 @@ def test_create_and_delete(self): assert sprint.state.upper() == "FUTURE" # THEN: the sprint .delete() is called successfully + def test_create_with_goal(self): + # GIVEN: The board, sprint name, and goal + # WHEN: we create the sprint + sprint = self.jira.create_sprint( + self.sprint_name, self.board.id, goal=self.sprint_goal + ) + # THEN: we create the sprint with a goal + assert isinstance(sprint.id, int) + assert sprint.name == self.sprint_name + assert sprint.goal == self.sprint_goal + + def test_update_sprint(self): + # GIVEN: The sprint ID + # WHEN: we update the sprint + sprint = self.jira.create_sprint( + self.sprint_name, self.board.id, goal=self.sprint_goal + ) + assert isinstance(sprint.id, int) + assert sprint.name == self.sprint_name + assert sprint.goal == self.sprint_goal + # THEN: the name changes + updated_sprint = self.jira.update_sprint( + sprint.id, + "new_name", + state="future", + startDate="2015-04-11T15:22:00.000+10:00", + endDate="2015-04-20T01:22:00.000+10:00", + ) + assert updated_sprint["name"] == "new_name" + def test_add_issue_to_sprint(self): # GIVEN: The sprint with self._create_sprint() as sprint: From 668562a3f8c73cbdcd34c7472930bd1b22019886 Mon Sep 17 00:00:00 2001 From: Mathieu Dupuy Date: Mon, 25 Mar 2024 14:12:12 +0100 Subject: [PATCH 10/14] migrate `setup.cfg` to `pyproject.toml` (#1776) --- pyproject.toml | 111 ++++++++++++++++++++++++++++++++++++++++++++++++- setup.cfg | 101 -------------------------------------------- 2 files changed, 110 insertions(+), 102 deletions(-) delete mode 100644 setup.cfg diff --git a/pyproject.toml b/pyproject.toml index 933415044..1b44402f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,112 @@ +[project] +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" +license = {text = "BSD-2-Clause"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Other Environment", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "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", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Internet :: WWW/HTTP", +] +keywords = ["api", "atlassian", "jira", "rest", "web"] +dependencies = [ + "defusedxml", + "packaging", + "Pillow>=2.1.0", + "requests-oauthlib>=1.1.0", + "requests>=2.10.0", + "requests_toolbelt", + "typing_extensions>=3.7.4.2", +] +dynamic = ["version"] + +[project.readme] +file = "README.rst" +content-type = "text/x-rst; charset=UTF-8" +# Do not include ChangeLog in description-file due to multiple reasons: +# - Unicode chars, see https://github.com/pycontribs/jira/issues/512 +# - Breaks ability to perform `python setup.py install` + +[project.urls] +Homepage = "https://github.com/pycontribs/jira" +"Bug Tracker" = "https://github.com/pycontribs/jira/issues" +"Release Management" = "https://github.com/pycontribs/jira/projects" +"CI: GitHub Actions" = "https://github.com/pycontribs/jira/actions" +"Source Code" = "https://github.com/pycontribs/jira.git" +Documentation = "https://jira.readthedocs.io" +Forum = "https://community.atlassian.com/t5/tag/jira-python/tg-p?sort=recent" + +[project.optional-dependencies] +cli = [ + "ipython>=4.0.0", + "keyring", +] +docs = [ + "sphinx>=5.0.0", + "sphinx-copybutton", + # HTML Theme + "furo", +] +opt = [ + "filemagic>=1.6", + "PyJWT", + "requests_jwt", + "requests_kerberos", +] +async = ["requests-futures>=0.9.7"] +test = [ + "docutils>=0.12", + "flaky", + "MarkupSafe>=0.23", + "oauthlib", + "pytest-cache", + "pytest-cov", + "pytest-instafail", + "pytest-sugar", + "pytest-timeout>=1.3.1", + "pytest-xdist>=2.2", + "pytest>=6.0.0", # MIT + "PyYAML>=5.1", # MIT + "requests_mock", # Apache-2 + "requires.io", # UNKNOWN!!! + "tenacity", # Apache-2 + "wheel>=0.24.0", # MIT + "yanc>=0.3.3", # GPL + "parameterized>=0.8.1", # BSD-3-Clause +] + +[project.scripts] +jirashell = "jira.jirashell:main" + +[tool.files] +packages = """ +jira""" + +[tool.setuptools] +include-package-data = true +zip-safe = false +platforms = ["any"] + +[tool.setuptools.packages] +find = {namespaces = false} + +[tool.setuptools.package-data] +jira = ["jira/py.typed"] + [build-system] requires = ["setuptools >= 60.0.0", "setuptools_scm[toml] >= 7.0.0"] build-backend = "setuptools.build_meta" @@ -14,7 +123,7 @@ addopts = '''-p no:xdist --durations=10 --tb=long -rxX -v --color=yes --junitxml=build/results.xml --cov-report=xml --cov jira''' -# these are important for distributed testing, to speedup their execution we minimize what we sync +# these are important for distributed testing, to speed up their execution we minimize what we sync rsyncdirs = ". jira demo docs" rsyncignore = ".git" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c25be632b..000000000 --- a/setup.cfg +++ /dev/null @@ -1,101 +0,0 @@ -[metadata] -name = jira -author = Ben Speakmon -author_email = ben.speakmon@gmail.com -maintainer = Sorin Sbarnea -maintainer_email = sorin.sbarnea@gmail.com -summary = Python library for interacting with JIRA via REST APIs. -long_description = file: README.rst -# Do not include ChangeLog in description-file due to multiple reasons: -# - Unicode chars, see https://github.com/pycontribs/jira/issues/512 -# - Breaks ability to perform `python setup.py install` -long_description_content_type = text/x-rst; charset=UTF-8 -url = https://github.com/pycontribs/jira -project_urls = - Bug Tracker = https://github.com/pycontribs/jira/issues - Release Management = https://github.com/pycontribs/jira/projects - CI: GitHub Actions = https://github.com/pycontribs/jira/actions - Source Code = https://github.com/pycontribs/jira.git - Documentation = https://jira.readthedocs.io - Forum = https://community.atlassian.com/t5/tag/jira-python/tg-p?sort=recent -requires_python = >=3.8 -platforms = any -license = BSD-2-Clause -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Other Environment - Intended Audience :: Developers - Intended Audience :: Information Technology - License :: OSI Approved :: BSD License - Operating System :: OS Independent - 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 - Topic :: Software Development :: Libraries :: Python Modules - Topic :: Internet :: WWW/HTTP -keywords = api, atlassian, jira, rest, web - -[files] -packages = - jira - -[options] -python_requires = >=3.8 -packages = find: -include_package_data = True -zip_safe = False -install_requires = - defusedxml - packaging - Pillow>=2.1.0 - requests-oauthlib>=1.1.0 - requests>=2.10.0 - requests_toolbelt - typing_extensions>=3.7.4.2 - -[options.extras_require] -cli = - ipython>=4.0.0 - keyring -docs = - sphinx>=5.0.0 - sphinx-copybutton - # HTML Theme - furo -opt = - filemagic>=1.6 - PyJWT - requests_jwt - requests_kerberos -async = - requests-futures>=0.9.7 -test = - docutils>=0.12 - flaky - MarkupSafe>=0.23 - oauthlib - pytest-cache - pytest-cov - pytest-instafail - pytest-sugar - pytest-timeout>=1.3.1 - pytest-xdist>=2.2 - pytest>=6.0.0 # MIT - PyYAML>=5.1 # MIT - requests_mock # Apache-2 - requires.io # UNKNOWN!!! - tenacity # Apache-2 - wheel>=0.24.0 # MIT - yanc>=0.3.3 # GPL - parameterized>=0.8.1 # BSD-3-Clause - -[options.entry_points] -console_scripts = - jirashell = jira.jirashell:main - -[options.package_data] -jira = jira/py.typed From 72855c1adc0d9ef764790166bb9e46394df18442 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Mon, 25 Mar 2024 13:14:32 +0000 Subject: [PATCH 11/14] run `tox run -e deps` --- .pre-commit-config.yaml | 6 ++-- constraints.txt | 68 ++++++++++++++++++++++------------------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e3ba3f06d..67f50e68e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,19 +27,19 @@ repos: require_serial: false additional_dependencies: [] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.1.9" + rev: "v0.3.4" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] # Run the formatter. - id: ruff-format - repo: https://github.com/adrienverge/yamllint - rev: v1.33.0 + rev: v1.35.1 hooks: - id: yamllint files: \.(yaml|yml)$ - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.9.0 hooks: - id: mypy additional_dependencies: diff --git a/constraints.txt b/constraints.txt index 3f1572ec0..9d59e9a7e 100644 --- a/constraints.txt +++ b/constraints.txt @@ -14,7 +14,7 @@ backcall==0.2.0 # via ipython beautifulsoup4==4.12.3 # via furo -certifi==2023.11.17 +certifi==2024.2.2 # via requests cffi==1.16.0 # via cryptography @@ -25,9 +25,9 @@ colorama==0.4.6 # ipython # pytest # sphinx -coverage==7.4.0 +coverage==7.4.4 # via pytest-cov -cryptography==41.0.7 +cryptography==42.0.5 # via # pyspnego # requests-kerberos @@ -49,45 +49,51 @@ executing==2.0.1 # via stack-data filemagic==1.6 # via jira (setup.cfg) -flaky==3.7.0 +flaky==3.8.1 # via jira (setup.cfg) -furo==2023.9.10 +furo==2024.1.29 # via jira (setup.cfg) idna==3.6 # via requests imagesize==1.4.1 # via sphinx -importlib-metadata==7.0.1 +importlib-metadata==7.1.0 # via # keyring # sphinx -importlib-resources==6.1.1 +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.0 +jaraco-classes==3.3.1 + # via keyring +jaraco-context==4.3.0 + # via keyring +jaraco-functools==4.0.0 # via keyring jedi==0.19.1 # via ipython jinja2==3.1.3 # via sphinx -keyring==24.3.0 +keyring==25.0.0 # via jira (setup.cfg) -markupsafe==2.1.3 +markupsafe==2.1.5 # via # jinja2 # jira (setup.cfg) matplotlib-inline==0.1.6 # via ipython -more-itertools==10.1.0 - # via jaraco-classes +more-itertools==10.2.0 + # via + # jaraco-classes + # jaraco-functools oauthlib==3.2.2 # via # jira (setup.cfg) # requests-oauthlib -packaging==23.2 +packaging==24.0 # via # jira (setup.cfg) # pytest @@ -99,9 +105,9 @@ parso==0.8.3 # via jedi pickleshare==0.7.5 # via ipython -pillow==10.1.0 +pillow==10.2.0 # via jira (setup.cfg) -pluggy==1.3.0 +pluggy==1.4.0 # via pytest prompt-toolkit==3.0.43 # via ipython @@ -120,7 +126,7 @@ pyjwt==2.8.0 # requests-jwt pyspnego==0.10.2 # via requests-kerberos -pytest==7.4.3 +pytest==8.1.1 # via # jira (setup.cfg) # pytest-cache @@ -131,17 +137,17 @@ pytest==7.4.3 # pytest-xdist pytest-cache==1.0 # via jira (setup.cfg) -pytest-cov==4.1.0 +pytest-cov==5.0.0 # via jira (setup.cfg) pytest-instafail==0.5.0 # via jira (setup.cfg) -pytest-sugar==0.9.7 +pytest-sugar==1.0.0 # via jira (setup.cfg) -pytest-timeout==2.2.0 +pytest-timeout==2.3.1 # via jira (setup.cfg) pytest-xdist==3.5.0 # via jira (setup.cfg) -pytz==2023.3.post1 +pytz==2024.1 # via babel pywin32-ctypes==0.2.2 # via keyring @@ -166,7 +172,7 @@ requests-kerberos==0.14.0 # via jira (setup.cfg) requests-mock==1.11.0 # via jira (setup.cfg) -requests-oauthlib==1.3.1 +requests-oauthlib==2.0.0 # via jira (setup.cfg) requests-toolbelt==1.0.0 # via jira (setup.cfg) @@ -192,15 +198,15 @@ sphinx-copybutton==0.5.2 # via jira (setup.cfg) sphinxcontrib-applehelp==1.0.4 # via sphinx -sphinxcontrib-devhelp==1.0.6 +sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==2.0.4 +sphinxcontrib-htmlhelp==2.0.1 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.6 +sphinxcontrib-qthelp==1.0.3 # via sphinx -sphinxcontrib-serializinghtml==1.1.10 +sphinxcontrib-serializinghtml==1.1.5 # via sphinx sspilib==0.1.0 # via pyspnego @@ -214,23 +220,23 @@ tomli==2.0.1 # via # coverage # pytest -traitlets==5.14.0 +traitlets==5.14.2 # via # ipython # matplotlib-inline -typing-extensions==4.9.0 +typing-extensions==4.10.0 # via # ipython # jira (setup.cfg) -urllib3==2.1.0 +urllib3==2.2.1 # via requests -wcwidth==0.2.12 +wcwidth==0.2.13 # via prompt-toolkit -wheel==0.42.0 +wheel==0.43.0 # via jira (setup.cfg) yanc==0.3.3 # via jira (setup.cfg) -zipp==3.17.0 +zipp==3.18.1 # via # importlib-metadata # importlib-resources From 9cf1b9e929d6bc2a360e4e6fe270c170075a4504 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Mon, 25 Mar 2024 13:15:02 +0000 Subject: [PATCH 12/14] use pypa/gh-action-pypi-publish to v1 instead of master --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3ad95e1c..473b73a39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: - name: Publish to test.pypi.org if: >- # "create" workflows run separately from "push" & "pull_request" github.event_name == 'release' - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@v1 with: password: ${{ secrets.testpypi_password }} repository_url: https://test.pypi.org/legacy/ @@ -44,6 +44,6 @@ jobs: - name: Publish to pypi.org if: >- # "create" workflows run separately from "push" & "pull_request" github.event_name == 'release' - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@v1 with: password: ${{ secrets.pypi_password }} From 795b91c415b84348438a57c74f94b093f11039a7 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Mon, 25 Mar 2024 13:18:41 +0000 Subject: [PATCH 13/14] update ruff config and apply latest ruff format style --- examples/auth.py | 1 + jira/__init__.py | 1 + jira/client.py | 6 +++--- jira/config.py | 1 + jira/jirashell.py | 1 + jira/resources.py | 12 ++++++------ make_local_jira_user.py | 1 + pyproject.toml | 25 +++++++++++++------------ tests/ruff.toml | 1 + tests/tests.py | 1 + 10 files changed, 29 insertions(+), 21 deletions(-) diff --git a/examples/auth.py b/examples/auth.py index ec1d67a7b..cda716403 100644 --- a/examples/auth.py +++ b/examples/auth.py @@ -1,4 +1,5 @@ """Some simple authentication examples.""" + from __future__ import annotations from collections import Counter diff --git a/jira/__init__.py b/jira/__init__.py index a12411832..6ec4cff6e 100644 --- a/jira/__init__.py +++ b/jira/__init__.py @@ -1,4 +1,5 @@ """The root of JIRA package namespace.""" + from __future__ import annotations try: diff --git a/jira/client.py b/jira/client.py index 0c456a3f9..ff311bcd6 100644 --- a/jira/client.py +++ b/jira/client.py @@ -4518,9 +4518,9 @@ def deactivate_user(self, username: str) -> str | int: raise JIRAError(f"Error Deactivating {username}: {e}") else: url = self.server_url + "/secure/admin/user/EditUser.jspa" - self._options["headers"][ - "Content-Type" - ] = "application/x-www-form-urlencoded; charset=UTF-8" + self._options["headers"]["Content-Type"] = ( + "application/x-www-form-urlencoded; charset=UTF-8" + ) user = self.user(username) userInfo = { "inline": "true", diff --git a/jira/config.py b/jira/config.py index 225865ee8..8216c3119 100644 --- a/jira/config.py +++ b/jira/config.py @@ -5,6 +5,7 @@ Also, this simplifies the scripts by not having to write the same initialization code for each script. """ + from __future__ import annotations import configparser diff --git a/jira/jirashell.py b/jira/jirashell.py index 4701ce3d5..c78fc69d5 100644 --- a/jira/jirashell.py +++ b/jira/jirashell.py @@ -3,6 +3,7 @@ Script arguments support changing the server and a persistent authentication over HTTP BASIC or Kerberos. """ + from __future__ import annotations import argparse diff --git a/jira/resources.py b/jira/resources.py index 0f48aa882..5922d5087 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -608,9 +608,9 @@ def update( # type: ignore[override] # incompatible supertype ignored DashboardItemProperty """ options = self._options.copy() - options[ - "path" - ] = f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}" + 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"]) @@ -628,9 +628,9 @@ def delete(self, dashboard_id: str, item_id: str) -> Response: # type: ignore[o Response """ options = self._options.copy() - options[ - "path" - ] = f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}" + options["path"] = ( + f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}" + ) return self._session.delete(self.JIRA_BASE_URL.format(**options)) diff --git a/make_local_jira_user.py b/make_local_jira_user.py index 8b44e9023..d8f984e91 100644 --- a/make_local_jira_user.py +++ b/make_local_jira_user.py @@ -1,4 +1,5 @@ """Attempts to create a test user, as the empty JIRA instance isn't provisioned with one.""" + from __future__ import annotations import sys diff --git a/pyproject.toml b/pyproject.toml index 1b44402f8..133e1bf3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,16 @@ check_untyped_defs = false disable_error_code = "annotation-unchecked" [tool.ruff] +# Same as Black. +line-length = 88 + +# Assume Python 3.8. (minimum supported) +target-version = "py38" + +# The source code paths to consider, e.g., when resolving first- vs. third-party imports +src = ["jira", "tests"] + +[tool.ruff.lint] select = [ "E", # pydocstyle "W", # pydocstyle @@ -169,27 +179,18 @@ ignore = [ "D417", ] -# Same as Black. -line-length = 88 - # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -# Assume Python 3.8. (minimum supported) -target-version = "py38" - -# The source code paths to consider, e.g., when resolving first- vs. third-party imports -src = ["jira", "tests"] - -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["jira", "tests"] required-imports = ["from __future__ import annotations"] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "jira/__init__.py" = [ "E402", # ignore import order in this file ] -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] # Use Google-style docstrings. convention = "google" diff --git a/tests/ruff.toml b/tests/ruff.toml index 8e70dda6b..9ed1ee61a 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -1,4 +1,5 @@ extend = "../pyproject.toml" +[lint] ignore = [ "E501", # We have way too many "line too long" errors at the moment "D", # Too many undocumented functions at the moment diff --git a/tests/tests.py b/tests/tests.py index fc101844a..f040150fa 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -8,6 +8,7 @@ resources/test_* : For tests related to resources test_* : For other tests of the non-resource elements of the jira package. """ + from __future__ import annotations import logging From eb0ec90e08ae24823e266b0128b852022d212982 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Mar 2024 23:55:40 +0000 Subject: [PATCH 14/14] Bump codecov/codecov-action from 3.1.4 to 4.1.0 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.4 to 4.1.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3.1.4...v4.1.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/jira_cloud_ci.yml | 2 +- .github/workflows/jira_server_ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/jira_cloud_ci.yml b/.github/workflows/jira_cloud_ci.yml index 99655f39e..2ecc5b47f 100644 --- a/.github/workflows/jira_cloud_ci.yml +++ b/.github/workflows/jira_cloud_ci.yml @@ -50,7 +50,7 @@ jobs: CI_JIRA_CLOUD_USER_TOKEN: ${{ secrets.CLOUD_USER_TOKEN }} - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3.1.4 + uses: codecov/codecov-action@v4.1.0 with: file: ./coverage.xml name: ${{ runner.os }}-${{ matrix.python-version }}-Cloud diff --git a/.github/workflows/jira_server_ci.yml b/.github/workflows/jira_server_ci.yml index 2eb41e485..6b1c46644 100644 --- a/.github/workflows/jira_server_ci.yml +++ b/.github/workflows/jira_server_ci.yml @@ -39,7 +39,7 @@ jobs: run: tox - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3.1.4 + uses: codecov/codecov-action@v4.1.0 with: file: ./coverage.xml name: ${{ runner.os }}-${{ matrix.python-version }}