From c09a1f09f7c96ee1dee316c2eafb5598f449e742 Mon Sep 17 00:00:00 2001 From: Adriaan Knapen Date: Wed, 20 May 2020 16:42:08 +0200 Subject: [PATCH 001/103] style(client): move comment inline as black and flake are contradicting --- jira/client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/jira/client.py b/jira/client.py index 57b2d1839..d020e792a 100644 --- a/jira/client.py +++ b/jira/client.py @@ -481,9 +481,8 @@ def __init__( self._create_kerberos_session(timeout, kerberos_options=kerberos_options) elif auth: self._create_cookie_auth(auth, timeout) - validate = ( - True - ) # always log in for cookie based auth, as we need a first request to be logged in + # always log in for cookie based auth, as we need a first request to be logged in + validate = True else: verify = self._options["verify"] self._session = ResilientSession(timeout=timeout) From 6b8e5e992ca37d7568d6c996c0cc7465d13cecad Mon Sep 17 00:00:00 2001 From: Adriaan Knapen Date: Sat, 25 Jan 2020 20:32:26 +0100 Subject: [PATCH 002/103] ci(travis): use addono/jira-run-standalone as testing server instance --- .travis.yml | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6e90c8aa7..4c3794f60 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,17 @@ --- language: python dist: xenial +services: + - docker # Build only commits on master and release tags for the "Build pushed branches" feature. # This prevents building twice on PRs originating from our repo ("Build pushed pull requests)". # See: # - https://github.com/travis-ci/travis-ci/issues/1147 # - https://docs.travis-ci.com/user/pull-requests/#double-builds-on-pull-requests -branches: - only: - - master - - /^\d+\.\d+(\.\d+)?(-\S*)?$/ +# branches: +# only: +# - master +# - /^\d+\.\d+(\.\d+)?(-\S*)?$/ cache: bundler: true @@ -28,6 +30,14 @@ stages: before_install: - pip install --upgrade tox tox-venv - rm -rf .tox +# Launch a Jira instance in detached mode + - docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone +# Wait until Jira has booted + - until $(curl -u $CI_JIRA_ADMIN:$CI_JIRA_ADMIN_PASSWORD --output /dev/null --silent --head --fail $CI_JIRA_URL/rest/api/2/permissions); do sleep 5; done +# Install this repository, as we will need it in the next step + - pip install . +# Attempt to create the test user, as the empty JIRA instance isn't provisioned with one + - (python -c "from jira import JIRA; JIRA('$CI_JIRA_URL', basic_auth=('$CI_JIRA_ADMIN', '$CI_JIRA_ADMIN_PASSWORD')).add_user('$CI_JIRA_USER', 'user@example.com', password='$CI_JIRA_USER_PASSWORD')" && echo "Created user '$CI_JIRA_USER'") || (echo "Failed creating user '$CI_JIRA_USER'" && docker logs --tail 500 jira) notifications: email: - pycontribs@googlegroups.com @@ -72,5 +82,9 @@ jobs: branch: master env: global: - - secure: "pGQGM5YmHvOgaKihOyzb3k6bdqLQnZQ2OXO9QrfXlXwtop3zvZQi80Q+01l230x2psDWlwvqWTknAjAt1w463fYXPwpoSvKVCsLSSbjrf2l56nrDqnoir+n0CBy288+eIdaGEfzcxDiuULeKjlg08zrqjcjLjW0bDbBrlTXsb5U=" - PIP_DISABLE_PIP_VERSION_CHECK=1 + - CI_JIRA_URL=http://localhost:2990/jira + - CI_JIRA_ADMIN=admin + - CI_JIRA_ADMIN_PASSWORD=admin + - CI_JIRA_USER=jira_user + - CI_JIRA_USER_PASSWORD=jira From af9608171f6f9e63c4935f5504e282d6597a26ff Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 24 Apr 2021 16:39:08 +0100 Subject: [PATCH 003/103] update setup.cfg --- setup.cfg | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/setup.cfg b/setup.cfg index 545190166..0c873d16f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,24 +1,24 @@ [metadata] name = jira author = Ben Speakmon -author-email = ben.speakmon@gmail.com +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 +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 -home-page = https://github.com/pycontribs/jira -project-urls = +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: Travis = https://travis-ci.com/pycontribs/jira Source Code = https://github.com/pycontribs/jira.git Documentation = https://jira.readthedocs.io/en/master/ Forum = https://community.atlassian.com/t5/tag/jira-python/tg-p?sort=recent -requires-python = >=3.5 +requires_python = >=3.5 platforms = any license = BSD classifiers = From 915aeb4091408da866888298fee5c7281b04e533 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 24 Apr 2021 17:00:46 +0100 Subject: [PATCH 004/103] updating tox side for Windows devs --- make_local_jira_user.py | 16 ++++++++++++++++ tox.ini | 15 +++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 make_local_jira_user.py diff --git a/make_local_jira_user.py b/make_local_jira_user.py new file mode 100644 index 000000000..8f060e086 --- /dev/null +++ b/make_local_jira_user.py @@ -0,0 +1,16 @@ +from jira import JIRA +from os import environ + + +try: + JIRA( + environ["CI_JIRA_URL"], + basic_auth=(environ["CI_JIRA_ADMIN"], environ["CI_JIRA_ADMIN_PASSWORD"]), + ).add_user( + environ["CI_JIRA_USER"], + "user@example.com", + password=environ["CI_JIRA_USER_PASSWORD"], + ) +except Exception as e: + if not "username already exists" in str(e): + raise e diff --git a/tox.ini b/tox.ini index 590b5a146..14d29ed5e 100644 --- a/tox.ini +++ b/tox.ini @@ -47,14 +47,22 @@ extras = test sitepackages=False commands= - bash -c 'find . | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm -rf' + git clean -xdf jira tests + ; bash -c 'find . | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm -rf' python -m pip check + python make_local_jira_user.py python -m pytest {posargs} setenv = PIP_LOG={envdir}/pip.log PIP_DISABLE_PIP_VERSION_CHECK=1 # Avoid 2020-01-01 warnings: https://github.com/pypa/pip/issues/6207 PYTHONWARNINGS=ignore:DEPRECATION::pip._internal.cli.base_command + CI_JIRA_URL=http://localhost:2990/jira + CI_JIRA_ADMIN=admin + CI_JIRA_ADMIN_PASSWORD=admin + CI_JIRA_USER=jira_user + CI_JIRA_USER_PASSWORD=jira + CI_JIRA_ISSUE=Task passenv = CI CI_JIRA_* @@ -65,6 +73,8 @@ passenv = TRAVIS* TWINE_* XDG_CACHE_HOME + # For Windows users, getpass.get_user() needs USERNAME + USERNAME envars = PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_USER=no @@ -85,7 +95,8 @@ deps = twine>=2.0.0 wheel>=0.33.6 commands = - rm -rf {toxinidir}/dist + git clean -xdf dist + ; rm -rf {toxinidir}/dist python setup.py check -m -s # disabled due to errors with older setuptools: # python setup.py sdist bdist_wheel From fefe09a6cd321d09cb32d0c7e4825a979bbe521c Mon Sep 17 00:00:00 2001 From: studioj Date: Mon, 5 Apr 2021 17:55:12 +0200 Subject: [PATCH 005/103] make getting started a little bit easier --- README.rst | 23 +++++++++++++++-------- test.local | 11 ++++++++--- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index c1aa693cb..3d9ea4f82 100644 --- a/README.rst +++ b/README.rst @@ -102,18 +102,25 @@ Setup * Fork_ repo * Keep it sync_'ed while you are developing * Install pyenv_ -* Install `Atlassian Jira Server`_ for testing - - make install-sdk -* pip install jira[test] -* Start up Jira Server - - atlas-run-standalone -* Test your changes - - make test +* develop and test, old way + * Install `Atlassian JIRA Server`_ for testing + - make install-sdk + * pip install jira[test] + * Start up Jira Server + - atlas-run-standalone + * Test your changes + - make test +* develop and test new way => at least to be able to fix the CI + * launch docker jira server + - docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone + * run tests + - ./test_local --tox + * run tests for one env only + - ./test_local --tox -e py37 .. _Fork: https://help.github.com/articles/fork-a-repo/ .. _sync: https://help.github.com/articles/syncing-a-fork/ .. _pyenv: https://amaral.northwestern.edu/resources/guides/pyenv-tutorial -.. _`Atlassian Jira Server`: https://www.atlassian.com/software/jira/download Credits diff --git a/test.local b/test.local index ebd061bbc..9c3ea7cbb 100755 --- a/test.local +++ b/test.local @@ -1,14 +1,19 @@ #!/bin/bash -# Settings for using the Vagrant VM from atlassian -# (see https://developer.atlassian.com/static/connect/docs/latest/developing/developing-locally.html) -# a user "jira_user" with password "jira" needs to be created manually export CI_JIRA_URL="http://localhost:2990/jira" export CI_JIRA_ADMIN="admin" export CI_JIRA_ADMIN_PASSWORD="admin" export CI_JIRA_USER=jira_user export CI_JIRA_USER_PASSWORD=jira export CI_JIRA_ISSUE=Task +#sudo docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone +echo waiting for the jira server to be up and running... +echo THIS CAN TAKE A WHILE on a vm this can take up to 10 minutes +start_time="$(date -u +%s)" +until $(curl -u $CI_JIRA_ADMIN:$CI_JIRA_ADMIN_PASSWORD --output /dev/null --silent --head --fail $CI_JIRA_URL/rest/api/2/permissions); do end_time="$(date -u +%s)";elapsed="$(($end_time-$start_time))"; echo "not running yet after $elapsed seconds $CI_JIRA_URL";sleep 5; done +pip install -e . +echo adding user $CI_JIRA_USER to $CI_JIRA_URL +(python -c "from jira import JIRA;jira_client = JIRA('$CI_JIRA_URL', basic_auth=('$CI_JIRA_ADMIN', '$CI_JIRA_ADMIN_PASSWORD'));a = jira_client.add_user('$CI_JIRA_USER', 'user@example.com', password='$CI_JIRA_USER_PASSWORD') if not jira_client.user('$CI_JIRA_USER') else None" && echo "Created user '$CI_JIRA_USER', or user was already present") || (echo "Failed creating user '$CI_JIRA_USER'" && docker logs --tail 500 jira) if [ "$1" = "--tox" ] ; then shift From d8336b01afc87ebdd2131ebfb4f8eb282171f5bf Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 24 Apr 2021 19:53:45 +0100 Subject: [PATCH 006/103] new properties that use rest_api_version and rest_path bugfix use v2 api compatible field names replace latest_rest with _get_latest_url replace versioned_rest with _get_url --- jira/client.py | 146 +++++++++++++++++++++++++------------------ jira/resources.py | 2 +- package.json | 2 +- tests/test_client.py | 16 +---- tests/tests.py | 33 +++------- tox.ini | 8 +-- 6 files changed, 99 insertions(+), 108 deletions(-) diff --git a/jira/client.py b/jira/client.py index d020e792a..c5ecc5a4a 100644 --- a/jira/client.py +++ b/jira/client.py @@ -464,7 +464,7 @@ def __init__( if self._options["server"].endswith("/"): self._options["server"] = self._options["server"][:-1] - context_path = urlparse(self._options["server"]).path + context_path = urlparse(self.server).path if len(context_path) > 0: self._options["context_path"] = context_path @@ -531,6 +531,11 @@ def __init__( for name in f["clauseNames"]: self._fields[name] = f["id"] + @property + def server(self): + """Return the server url""" + return self._options["server"] + def _create_cookie_auth(self, auth, timeout): self._session = ResilientSession(timeout=timeout) self._session.auth = JiraCookieAuth(self._session, self.session, auth) @@ -733,7 +738,7 @@ def _get_items_from_page(self, item_type, items_key, resource): def client_info(self): """Get the server this client is connected to.""" - return self._options["server"] + return self.server # Universal resource loading @@ -798,7 +803,7 @@ def set_application_property(self, key, value): :param value: value to assign to the property :type value: str """ - url = self._options["server"] + "/rest/api/latest/application-properties/" + key + url = self._get_latest_url("application-properties/" + key) payload = {"id": key, "value": value} return self._session.put(url, data=json.dumps(payload)) @@ -812,7 +817,7 @@ def applicationlinks(self, cached=True): return self._applicationlinks # url = self._options['server'] + '/rest/applinks/latest/applicationlink' - url = self._options["server"] + "/rest/applinks/latest/listApplicationlinks" + url = self.server + "/rest/applinks/latest/listApplicationlinks" r = self._session.get(url) @@ -1222,7 +1227,7 @@ def add_group(self, groupname): :return: Boolean - True if successful. :rtype: bool """ - url = self._options["server"] + "/rest/api/latest/group" + url = self._get_latest_url("group") # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 @@ -1247,7 +1252,7 @@ def remove_group(self, groupname): """ # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 - url = self._options["server"] + "/rest/api/latest/group" + url = self._get_latest_url("group") x = {"groupname": groupname} self._session.delete(url, params=x) return True @@ -1402,7 +1407,7 @@ def supports_service_desk(self): :rtype: bool """ - url = self._options["server"] + "/rest/servicedeskapi/info" + url = self.server + "/rest/servicedeskapi/info" headers = {"X-ExperimentalApi": "opt-in"} try: r = self._session.get(url, headers=headers) @@ -1420,7 +1425,7 @@ def create_customer(self, email, displayName): :rtype: Customer """ - url = self._options["server"] + "/rest/servicedeskapi/customer" + url = self.server + "/rest/servicedeskapi/customer" headers = {"X-ExperimentalApi": "opt-in"} r = self._session.post( url, @@ -1440,7 +1445,7 @@ def service_desks(self): :rtype: List[ServiceDesk] """ - url = self._options["server"] + "/rest/servicedeskapi/servicedesk" + url = self.server + "/rest/servicedeskapi/servicedesk" headers = {"X-ExperimentalApi": "opt-in"} r_json = json_loads(self._session.get(url, headers=headers)) print(r_json) @@ -1500,7 +1505,7 @@ def create_customer_request(self, fields=None, prefetch=True, **fieldargs): elif isinstance(p, str): data["requestTypeId"] = self.request_type_by_name(service_desk, p).id - url = self._options["server"] + "/rest/servicedeskapi/request" + url = self.server + "/rest/servicedeskapi/request" headers = {"X-ExperimentalApi": "opt-in"} r = self._session.post(url, headers=headers, data=json.dumps(data)) @@ -1558,13 +1563,13 @@ def createmeta( params["expand"] = expand return self._get_json("issue/createmeta", params) - def _get_user_accountid(self, user): - """Internal method for translating an user to an accountId.""" + def _get_user_key(self, user): + """Internal method for translating an user (str) to an key.""" try: - accountId = self.search_users(user, maxResults=1)[0].accountId + key = self.search_users(user, maxResults=1)[0].key except Exception as e: raise JIRAError(e) - return accountId + return key # non-resource @translate_resource_args @@ -1578,13 +1583,8 @@ def assign_issue(self, issue, assignee): :rtype: bool """ - url = ( - self._options["server"] - + "/rest/api/latest/issue/" - + str(issue) - + "/assignee" - ) - payload = {"accountId": self._get_user_accountid(assignee)} + url = self._get_latest_url("issue/{}/assignee".format(str(issue))) + payload = {"name": self._get_user_key(assignee)} # 'key' and 'name' are deprecated in favor of accountId r = self._session.put(url, data=json.dumps(payload)) raise_on_error(r) @@ -1598,7 +1598,7 @@ def comments(self, issue): :type issue: str :rtype: List[Comment] """ - r_json = self._get_json("issue/" + str(issue) + "/comment") + r_json = self._get_json("issue/{}/comment".format(str(issue))) comments = [ Comment(self._options, self._session, raw_comment_json) @@ -1752,7 +1752,7 @@ def add_remote_link( # check if the link comes from one of the configured application links for x in applicationlinks: - if x["application"]["displayUrl"] == self._options["server"]: + if x["application"]["displayUrl"] == self.server: data["globalId"] = "appId=%s&issueId=%s" % ( x["application"]["id"], destination.raw["id"], @@ -1917,7 +1917,7 @@ def add_watcher(self, issue, watcher): """Add a user to an issue's watchers list. :param issue: ID or key of the issue affected - :param watcher: username of the user to add to the watchers list + :param watcher: key of the user to add to the watchers list """ url = self._get_url("issue/" + str(issue) + "/watchers") self._session.post(url, data=json.dumps(watcher)) @@ -1927,11 +1927,12 @@ def remove_watcher(self, issue, watcher): """Remove a user from an issue's watch list. :param issue: ID or key of the issue affected - :param watcher: accountId of the user to remove from the watchers list + :param watcher: key of the user to remove from the watchers list :rtype: Response """ url = self._get_url("issue/" + str(issue) + "/watchers") - params = {"accountId": watcher} + # https://docs.atlassian.com/software/jira/docs/api/REST/8.13.6/#api/2/issue-removeWatcher + params = {"username": watcher} result = self._session.delete(url, params=params) return result @@ -2152,7 +2153,7 @@ def request_types(self, service_desk): if hasattr(service_desk, "id"): service_desk = service_desk.id url = ( - self._options["server"] + self.server + "/rest/servicedeskapi/servicedesk/%s/requesttype" % service_desk ) headers = {"X-ExperimentalApi": "opt-in"} @@ -2989,7 +2990,7 @@ def session(self): def kill_session(self): """Destroy the session of the current authenticated user.""" - url = self._options["server"] + "/rest/auth/latest/session" + url = self.server + "/rest/auth/latest/session" return self._session.delete(url) # Websudo @@ -3001,7 +3002,7 @@ def kill_websudo(self): :rtype: Optional[Any] """ if self.deploymentType != "Cloud": - url = self._options["server"] + "/rest/auth/1/websudo" + url = self.server + "/rest/auth/1/websudo" return self._session.delete(url) # Utilities @@ -3096,7 +3097,8 @@ def _set_avatar(self, params, url, avatar): return self._session.put(url, params=params, data=json.dumps(data)) def _get_url(self, path, base=JIRA_BASE_URL): - """ Returns the full url based on Jira base url and the path provided + """ Returns the full url based on Jira base url and the path provided. + Using the API version specified during the __init__. :param path: The subpath desired. :type path: str @@ -3111,6 +3113,23 @@ def _get_url(self, path, base=JIRA_BASE_URL): options.update({"path": path}) return base.format(**options) + def _get_latest_url(self, path, base=JIRA_BASE_URL): + """ Returns the full url based on Jira base url and the path provided. + Using the latest API endpoint. + + :param path: The subpath desired. + :type path: str + :param base: The base url which should be prepended to the path + :type base: Optional[str] + + :return Fully qualified URL + :rtype: str + + """ + options = self._options.copy() + options.update({"path": path, "rest_api_version": "latest"}) + return base.format(**options) + def _get_json(self, path, params=None, base=JIRA_BASE_URL): """Get the json for a given path and params. @@ -3194,7 +3213,7 @@ def rename_user(self, old_user, new_user): """ if self._version > (6, 0, 0): - url = self._options["server"] + "/rest/api/latest/user" + url = self._get_latest_url("user") payload = {"name": new_user} params = {"username": old_user} @@ -3219,7 +3238,7 @@ def delete_user(self, username): """ - url = self._options["server"] + "/rest/api/latest/user/?username=%s" % username + url = self._get_latest_url("user/?username=%s" % username) r = self._session.delete(url) if 200 <= r.status_code <= 299: @@ -3272,7 +3291,7 @@ def deactivate_user(self, username): logging.error("Error Deactivating %s: %s" % (username, e)) raise JIRAError("Error Deactivating %s: %s" % (username, e)) else: - url = self._options["server"] + "/secure/admin/user/EditUser.jspa" + url = self.server + "/secure/admin/user/EditUser.jspa" self._options["headers"][ "Content-Type" ] = "application/x-www-form-urlencoded; charset=UTF-8" @@ -3316,7 +3335,7 @@ def reindex(self, force=False, background=True): else: indexingStrategy = "stoptheworld" - url = self._options["server"] + "/secure/admin/jira/IndexReIndex.jspa" + url = self.server + "/secure/admin/jira/IndexReIndex.jspa" r = self._session.get(url, headers=self._options["headers"]) if r.status_code == 503: @@ -3348,11 +3367,11 @@ def reindex(self, force=False, background=True): def backup(self, filename="backup.zip", attachments=False): """Will call jira export to backup as zipped xml. Returning with success does not mean that the backup process finished.""" if self.deploymentType == "Cloud": - url = self._options["server"] + "/rest/backup/1/export/runbackup" + url = self.server + "/rest/backup/1/export/runbackup" payload = json.dumps({"cbAttachments": attachments}) self._options["headers"]["X-Requested-With"] = "XMLHttpRequest" else: - url = self._options["server"] + "/secure/admin/XmlBackup.jspa" + url = self.server + "/secure/admin/XmlBackup.jspa" payload = {"filename": filename} try: r = self._session.post(url, headers=self._options["headers"], data=payload) @@ -3372,7 +3391,7 @@ def backup_progress(self): epoch_time = int(time.time() * 1000) if self.deploymentType == "Cloud": url = ( - self._options["server"] + "/rest/obm/1.0/getprogress?_=%i" % epoch_time + self.server + "/rest/obm/1.0/getprogress?_=%i" % epoch_time ) else: logging.warning("This functionality is not available in Server version") @@ -3416,7 +3435,7 @@ def backup_download(self, filename=None): return None remote_file = self.backup_progress()["fileName"] local_file = filename or remote_file - url = self._options["server"] + "/webdav/backupmanager/" + remote_file + url = self.server + "/webdav/backupmanager/" + remote_file try: logging.debug("Writing file to %s" % local_file) with open(local_file, "wb") as file: @@ -3467,7 +3486,7 @@ def delete_project(self, pid): if hasattr(pid, "id"): pid = pid.id - url = self._options["server"] + "/rest/api/2/project/%s" % pid + url = self._get_url("project/%s" % pid) r = self._session.delete(url) if r.status_code == 403: raise JIRAError("Not enough permissions to delete project") @@ -3476,7 +3495,7 @@ def delete_project(self, pid): return r.ok def _gain_sudo_session(self, options, destination): - url = self._options["server"] + "/secure/admin/WebSudoAuthenticate.jspa" + url = self.server + "/secure/admin/WebSudoAuthenticate.jspa" if not self._session.auth: self._session.auth = get_netrc_auth(url) @@ -3500,7 +3519,7 @@ def _gain_sudo_session(self, options, destination): @lru_cache(maxsize=None) def templates(self): - url = self._options["server"] + "/rest/project-templates/latest/templates" + url = self.server + "/rest/project-templates/latest/templates" r = self._session.get(url) data = json_loads(r) @@ -3516,7 +3535,7 @@ def templates(self): @lru_cache(maxsize=None) def permissionschemes(self): - url = self._options["server"] + "/rest/api/3/permissionscheme" + url = self._get_url("permissionscheme") r = self._session.get(url) data = json_loads(r)["permissionSchemes"] @@ -3526,7 +3545,7 @@ def permissionschemes(self): @lru_cache(maxsize=None) def issuesecurityschemes(self): - url = self._options["server"] + "/rest/api/3/issuesecurityschemes" + url = self._get_url("issuesecurityschemes") r = self._session.get(url) data = json_loads(r)["issueSecuritySchemes"] @@ -3536,7 +3555,7 @@ def issuesecurityschemes(self): @lru_cache(maxsize=None) def projectcategories(self): - url = self._options["server"] + "/rest/api/3/projectCategory" + url = self._get_url("projectCategory") r = self._session.get(url) data = json_loads(r) @@ -3546,7 +3565,7 @@ def projectcategories(self): @lru_cache(maxsize=None) def avatars(self, entity="project"): - url = self._options["server"] + "/rest/api/3/avatar/%s/system" % entity + url = self._get_url("avatar/%s/system" % entity) r = self._session.get(url) data = json_loads(r)["system"] @@ -3556,7 +3575,7 @@ def avatars(self, entity="project"): @lru_cache(maxsize=None) def notificationschemes(self): # TODO(ssbarnea): implement pagination support - url = self._options["server"] + "/rest/api/3/notificationscheme" + url = self._get_url("notificationscheme") r = self._session.get(url) data = json_loads(r) @@ -3565,7 +3584,7 @@ def notificationschemes(self): @lru_cache(maxsize=None) def screens(self): # TODO(ssbarnea): implement pagination support - url = self._options["server"] + "/rest/api/3/screens" + url = self._get_url("screens") r = self._session.get(url) data = json_loads(r) @@ -3574,7 +3593,7 @@ def screens(self): @lru_cache(maxsize=None) def workflowscheme(self): # TODO(ssbarnea): implement pagination support - url = self._options["server"] + "/rest/api/3/workflowschemes" + url = self._get_url("workflowschemes") r = self._session.get(url) data = json_loads(r) @@ -3583,7 +3602,7 @@ def workflowscheme(self): @lru_cache(maxsize=None) def workflows(self): # TODO(ssbarnea): implement pagination support - url = self._options["server"] + "/rest/api/3/workflow" + url = self._get_url("workflow") r = self._session.get(url) data = json_loads(r) @@ -3591,7 +3610,7 @@ def workflows(self): def delete_screen(self, id): - url = self._options["server"] + "/rest/api/3/screens/%s" % id + url = self._get_url("screens/%s" % id) r = self._session.delete(url) data = json_loads(r) @@ -3601,7 +3620,7 @@ def delete_screen(self, id): def delete_permissionscheme(self, id): - url = self._options["server"] + "/rest/api/3/permissionscheme/%s" % id + url = self._get_url("permissionscheme/%s" % id) r = self._session.delete(url) data = json_loads(r) @@ -3630,7 +3649,7 @@ def create_project( :type: str :param name: If not specified it will use the key value. :type name: Optional[str] - :param assignee: accountId of the lead, if not specified it will use current user. + :param assignee: key of the lead, if not specified it will use current user. :type assignee: Optional[str] :param type: Determines the type of project should be created. :type ptype: Optional[str] @@ -3645,7 +3664,7 @@ def create_project( template_key = None if assignee is None: - assignee = self.current_user("accountId") + assignee = self.current_user() if name is None: name = key @@ -3681,8 +3700,12 @@ def create_project( # https://jira.atlassian.com/browse/JRASERVER-59658 # preference list for picking a default template if not template_name: - template_key = "com.pyxis.greenhopper.jira:gh-simplified-basic" + # https://confluence.atlassian.com/jirakb/creating-projects-via-rest-api-in-jira-963651978.html + template_key = ( + "com.pyxis.greenhopper.jira:basic-software-development-template" + ) + # https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-get # template_keys = [ # "com.pyxis.greenhopper.jira:gh-simplified-agility-kanban", # "com.pyxis.greenhopper.jira:gh-simplified-agility-scrum", @@ -3742,7 +3765,8 @@ def create_project( "key": key, "projectTypeKey": ptype, "projectTemplateKey": template_key, - "leadAccountId": assignee, + "lead": assignee, + # "leadAccountId": assignee, "assigneeType": "PROJECT_LEAD", "description": "", # "avatarId": 13946, @@ -3755,7 +3779,7 @@ def create_project( if projectCategory: payload["categoryId"] = int(projectCategory) - url = self._options["server"] + "/rest/api/3/project" + url = self._get_url("project") r = self._session.post(url, data=json.dumps(payload)) r.raise_for_status() @@ -3805,7 +3829,7 @@ def add_user( fullname = username # TODO(ssbarnea): default the directoryID to the first directory in jira instead # of 1 which is the internal one. - url = self._options["server"] + "/rest/api/latest/user" + url = self._get_latest_url("user") # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 @@ -3846,7 +3870,7 @@ def add_user_to_group(self, username, group): :return: json response from Jira server for success or a value that evaluates as False in case of failure. :rtype: Union[bool,Dict[str,Any]] """ - url = self._options["server"] + "/rest/api/latest/group/user" + url = self._get_latest_url("group/user") x = {"groupname": group} y = {"name": username} @@ -3864,7 +3888,7 @@ def remove_user_from_group(self, username, groupname): :param username: The user to remove from the group. :param groupname: The group that the user will be removed from. """ - url = self._options["server"] + "/rest/api/latest/group/user" + url = self._get_latest_url("group/user") x = {"groupname": groupname, "username": username} self._session.delete(url, params=x) @@ -3880,7 +3904,7 @@ def role(self): """ # https://developer.atlassian.com/cloud/jira/platform/rest/v3/?utm_source=%2Fcloud%2Fjira%2Fplatform%2Frest%2F&utm_medium=302#api-rest-api-3-role-get - url = self._options["server"] + "/rest/api/latest/role" + url = self._get_latest_url("role") r = self._session.get(url) return json_loads(r) @@ -3889,7 +3913,7 @@ def role(self): # Experimental support for iDalko Grid, expect API to change as it's using private APIs currently # https://support.idalko.com/browse/IGRID-1017 def get_igrid(self, issueid, customfield, schemeid): - url = self._options["server"] + "/rest/idalko-igrid/1.0/datagrid/data" + url = self.server + "/rest/idalko-igrid/1.0/datagrid/data" if str(customfield).isdigit(): customfield = "customfield_%s" % customfield params = { diff --git a/jira/resources.py b/jira/resources.py index 6464dd449..10d05130c 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -1092,7 +1092,7 @@ def dict2resource(raw, top=None, options=None, session=None): r"securitylevel/[^/]+$": SecurityLevel, r"status/[^/]+$": Status, r"statuscategory/[^/]+$": StatusCategory, - r"user\?(username|accountId).+$": User, + r"user\?(username|key).+$": User, r"group\?groupname.+$": Group, r"version/[^/]+$": Version, # GreenHopper specific resources diff --git a/package.json b/package.json index 070c353cd..2c3f8660c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/pycontribs/jira.git" }, "dependencies": { - "cspell": "^4.0.23", + "cspell": "^4.2.8", "npm": "^6.10.0" } } diff --git a/tests/test_client.py b/tests/test_client.py index 1c49fdbc6..52408f129 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -73,24 +73,15 @@ def test_delete_inexistent_project(cl_admin): def test_templates(cl_admin): - templates = cl_admin.templates() + templates = set(cl_admin.templates()) expected_templates = set( filter( None, """ -Agility -Basic -Bug tracking -Content Management -Customer service -Document Approval -IT Service Desk +Basic software development Kanban software development -Lead Tracking Process management -Procurement Project management -Recruitment Scrum software development Task management """.split( @@ -99,8 +90,7 @@ def test_templates(cl_admin): ) ) - for t in expected_templates: - assert t in templates + assert templates == expected_templates def test_result_list(): diff --git a/tests/tests.py b/tests/tests.py index 32f1d6758..774798f42 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -295,7 +295,7 @@ def __init__(self): ): break except Exception as e: - if "A project with that name already exists" not in e.text: + if "A project with that name already exists" not in str(e): raise e self.project_a_id = self.jira_admin.project(self.project_a).id self.jira_admin.create_project(self.project_b, self.project_b_name) @@ -453,7 +453,7 @@ def test_application_property(self): clone_prefix = self.jira.application_properties( key="jira.lf.text.headingcolour" ) - self.assertEqual(clone_prefix["value"], "#292929") + self.assertEqual(clone_prefix["value"], "#172b4d") def test_set_application_property(self): prop = "jira.lf.favicon.hires.url" @@ -1133,12 +1133,10 @@ def test_editmeta(self): "comment", "components", "description", - "environment", "fixVersions", "issuelinks", "labels", "summary", - "versions", } for i in (self.issue_1, self.issue_2): meta = self.jira.editmeta(i) @@ -1304,15 +1302,15 @@ def test_votes_with_issue_obj(self): def test_add_remove_watcher(self): # removing it in case it exists, so we know its state - self.jira.remove_watcher(self.issue_1, self.test_manager.user_admin.accountId) + self.jira.remove_watcher(self.issue_1, self.test_manager.user_admin.key) init_watchers = self.jira.watchers(self.issue_1).watchCount # adding a new watcher - self.jira.add_watcher(self.issue_1, self.test_manager.user_admin.accountId) + self.jira.add_watcher(self.issue_1, self.test_manager.user_admin.key) self.assertEqual(self.jira.watchers(self.issue_1).watchCount, init_watchers + 1) # now we verify that remove does indeed remove watchers - self.jira.remove_watcher(self.issue_1, self.test_manager.user_admin.accountId) + self.jira.remove_watcher(self.issue_1, self.test_manager.user_admin.key) new_watchers = self.jira.watchers(self.issue_1).watchCount self.assertEqual(init_watchers, new_watchers) @@ -1598,12 +1596,7 @@ def test_project_versions(self): self.assertEqual(test.name, name) i = self.jira.issue(JiraTestManager().project_b_issue1) - i.update( - fields={ - "versions": [{"id": version.id}], - "fixVersions": [{"id": version.id}], - } - ) + i.update(fields={"fixVersions": [{"id": version.id}]}) version.delete() def test_get_project_version_by_name(self): @@ -1620,12 +1613,7 @@ def test_get_project_version_by_name(self): self.assertEqual(not_found_version, None) i = self.jira.issue(JiraTestManager().project_b_issue1) - i.update( - fields={ - "versions": [{"id": version.id}], - "fixVersions": [{"id": version.id}], - } - ) + i.update(fields={"fixVersions": [{"id": version.id}]}) version.delete() def test_rename_version(self): @@ -1647,12 +1635,7 @@ def test_rename_version(self): self.assertEqual(not_found_version, None) i = self.jira.issue(JiraTestManager().project_b_issue1) - i.update( - fields={ - "versions": [{"id": version.id}], - "fixVersions": [{"id": version.id}], - } - ) + i.update(fields={"fixVersions": [{"id": version.id}]}) version.delete() def test_project_versions_with_project_obj(self): diff --git a/tox.ini b/tox.ini index 14d29ed5e..2a57554c8 100644 --- a/tox.ini +++ b/tox.ini @@ -48,7 +48,6 @@ extras = sitepackages=False commands= git clean -xdf jira tests - ; bash -c 'find . | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm -rf' python -m pip check python make_local_jira_user.py python -m pytest {posargs} @@ -80,11 +79,7 @@ envars = PIP_USER=no whitelist_externals = bash - echo - find - grep - rm - xargs + git [testenv:pkg] deps = @@ -96,7 +91,6 @@ deps = wheel>=0.33.6 commands = git clean -xdf dist - ; rm -rf {toxinidir}/dist python setup.py check -m -s # disabled due to errors with older setuptools: # python setup.py sdist bdist_wheel From a1a9e28296fc5e2bae96e7b0da2e83f4764579ca Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sun, 25 Apr 2021 14:28:49 +0100 Subject: [PATCH 007/103] replace server property with server_url property --- jira/client.py | 44 +++++++++++++++++++++----------------------- jira/jirashell.py | 2 +- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/jira/client.py b/jira/client.py index c5ecc5a4a..53d53bd7e 100644 --- a/jira/client.py +++ b/jira/client.py @@ -464,7 +464,7 @@ def __init__( if self._options["server"].endswith("/"): self._options["server"] = self._options["server"][:-1] - context_path = urlparse(self.server).path + context_path = urlparse(self.server_url).path if len(context_path) > 0: self._options["context_path"] = context_path @@ -532,7 +532,7 @@ def __init__( self._fields[name] = f["id"] @property - def server(self): + def server_url(self): """Return the server url""" return self._options["server"] @@ -738,7 +738,7 @@ def _get_items_from_page(self, item_type, items_key, resource): def client_info(self): """Get the server this client is connected to.""" - return self.server + return self.server_url # Universal resource loading @@ -817,7 +817,7 @@ def applicationlinks(self, cached=True): return self._applicationlinks # url = self._options['server'] + '/rest/applinks/latest/applicationlink' - url = self.server + "/rest/applinks/latest/listApplicationlinks" + url = self.server_url + "/rest/applinks/latest/listApplicationlinks" r = self._session.get(url) @@ -1407,7 +1407,7 @@ def supports_service_desk(self): :rtype: bool """ - url = self.server + "/rest/servicedeskapi/info" + url = self.server_url + "/rest/servicedeskapi/info" headers = {"X-ExperimentalApi": "opt-in"} try: r = self._session.get(url, headers=headers) @@ -1425,7 +1425,7 @@ def create_customer(self, email, displayName): :rtype: Customer """ - url = self.server + "/rest/servicedeskapi/customer" + url = self.server_url + "/rest/servicedeskapi/customer" headers = {"X-ExperimentalApi": "opt-in"} r = self._session.post( url, @@ -1445,7 +1445,7 @@ def service_desks(self): :rtype: List[ServiceDesk] """ - url = self.server + "/rest/servicedeskapi/servicedesk" + url = self.server_url + "/rest/servicedeskapi/servicedesk" headers = {"X-ExperimentalApi": "opt-in"} r_json = json_loads(self._session.get(url, headers=headers)) print(r_json) @@ -1505,7 +1505,7 @@ def create_customer_request(self, fields=None, prefetch=True, **fieldargs): elif isinstance(p, str): data["requestTypeId"] = self.request_type_by_name(service_desk, p).id - url = self.server + "/rest/servicedeskapi/request" + url = self.server_url + "/rest/servicedeskapi/request" headers = {"X-ExperimentalApi": "opt-in"} r = self._session.post(url, headers=headers, data=json.dumps(data)) @@ -1752,7 +1752,7 @@ def add_remote_link( # check if the link comes from one of the configured application links for x in applicationlinks: - if x["application"]["displayUrl"] == self.server: + if x["application"]["displayUrl"] == self.server_url: data["globalId"] = "appId=%s&issueId=%s" % ( x["application"]["id"], destination.raw["id"], @@ -2153,7 +2153,7 @@ def request_types(self, service_desk): if hasattr(service_desk, "id"): service_desk = service_desk.id url = ( - self.server + self.server_url + "/rest/servicedeskapi/servicedesk/%s/requesttype" % service_desk ) headers = {"X-ExperimentalApi": "opt-in"} @@ -2990,7 +2990,7 @@ def session(self): def kill_session(self): """Destroy the session of the current authenticated user.""" - url = self.server + "/rest/auth/latest/session" + url = self.server_url + "/rest/auth/latest/session" return self._session.delete(url) # Websudo @@ -3002,7 +3002,7 @@ def kill_websudo(self): :rtype: Optional[Any] """ if self.deploymentType != "Cloud": - url = self.server + "/rest/auth/1/websudo" + url = self.server_url + "/rest/auth/1/websudo" return self._session.delete(url) # Utilities @@ -3291,7 +3291,7 @@ def deactivate_user(self, username): logging.error("Error Deactivating %s: %s" % (username, e)) raise JIRAError("Error Deactivating %s: %s" % (username, e)) else: - url = self.server + "/secure/admin/user/EditUser.jspa" + url = self.server_url + "/secure/admin/user/EditUser.jspa" self._options["headers"][ "Content-Type" ] = "application/x-www-form-urlencoded; charset=UTF-8" @@ -3335,7 +3335,7 @@ def reindex(self, force=False, background=True): else: indexingStrategy = "stoptheworld" - url = self.server + "/secure/admin/jira/IndexReIndex.jspa" + url = self.server_url + "/secure/admin/jira/IndexReIndex.jspa" r = self._session.get(url, headers=self._options["headers"]) if r.status_code == 503: @@ -3367,11 +3367,11 @@ def reindex(self, force=False, background=True): def backup(self, filename="backup.zip", attachments=False): """Will call jira export to backup as zipped xml. Returning with success does not mean that the backup process finished.""" if self.deploymentType == "Cloud": - url = self.server + "/rest/backup/1/export/runbackup" + url = self.server_url + "/rest/backup/1/export/runbackup" payload = json.dumps({"cbAttachments": attachments}) self._options["headers"]["X-Requested-With"] = "XMLHttpRequest" else: - url = self.server + "/secure/admin/XmlBackup.jspa" + url = self.server_url + "/secure/admin/XmlBackup.jspa" payload = {"filename": filename} try: r = self._session.post(url, headers=self._options["headers"], data=payload) @@ -3390,9 +3390,7 @@ def backup_progress(self): """ epoch_time = int(time.time() * 1000) if self.deploymentType == "Cloud": - url = ( - self.server + "/rest/obm/1.0/getprogress?_=%i" % epoch_time - ) + url = self.server_url + "/rest/obm/1.0/getprogress?_=%i" % epoch_time else: logging.warning("This functionality is not available in Server version") return None @@ -3435,7 +3433,7 @@ def backup_download(self, filename=None): return None remote_file = self.backup_progress()["fileName"] local_file = filename or remote_file - url = self.server + "/webdav/backupmanager/" + remote_file + url = self.server_url + "/webdav/backupmanager/" + remote_file try: logging.debug("Writing file to %s" % local_file) with open(local_file, "wb") as file: @@ -3495,7 +3493,7 @@ def delete_project(self, pid): return r.ok def _gain_sudo_session(self, options, destination): - url = self.server + "/secure/admin/WebSudoAuthenticate.jspa" + url = self.server_url + "/secure/admin/WebSudoAuthenticate.jspa" if not self._session.auth: self._session.auth = get_netrc_auth(url) @@ -3519,7 +3517,7 @@ def _gain_sudo_session(self, options, destination): @lru_cache(maxsize=None) def templates(self): - url = self.server + "/rest/project-templates/latest/templates" + url = self.server_url + "/rest/project-templates/latest/templates" r = self._session.get(url) data = json_loads(r) @@ -3913,7 +3911,7 @@ def role(self): # Experimental support for iDalko Grid, expect API to change as it's using private APIs currently # https://support.idalko.com/browse/IGRID-1017 def get_igrid(self, issueid, customfield, schemeid): - url = self.server + "/rest/idalko-igrid/1.0/datagrid/data" + url = self.server_url + "/rest/idalko-igrid/1.0/datagrid/data" if str(customfield).isdigit(): customfield = "customfield_%s" % customfield params = { diff --git a/jira/jirashell.py b/jira/jirashell.py index dc413581e..aa1f0221d 100644 --- a/jira/jirashell.py +++ b/jira/jirashell.py @@ -362,7 +362,7 @@ def main(): from IPython.frontend.terminal.embed import InteractiveShellEmbed ip_shell = InteractiveShellEmbed( - banner1="" + banner1="" ) ip_shell("*** Jira shell active; client is in 'jira'." " Press Ctrl-D to exit.") except Exception as e: From 472f50610d48a1070ea229d188437d4ba54d1427 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sun, 25 Apr 2021 13:03:23 +0100 Subject: [PATCH 008/103] bump pytest versions --- setup.cfg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 0c873d16f..22d6065bc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -86,8 +86,8 @@ test = pytest-instafail pytest-sugar pytest-timeout>=1.3.1 - pytest-xdist>=1.14 - pytest>=5.0.0,<6.0 # MIT + pytest-xdist>=2.2 + pytest>=6.0.0,<7.0 # MIT PyYAML>=5.1 # MIT requests_mock # Apache-2 requires.io # UNKNOWN!!! From fb9ebb5af59ddfdf561ebd279645d21c506fe197 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sun, 25 Apr 2021 14:02:08 +0100 Subject: [PATCH 009/103] tidy some travis things --- .travis.yml | 22 +++++++++++----------- make_local_jira_user.py | 5 ++++- test.local | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4c3794f60..9c6f24a5c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,10 +8,10 @@ services: # See: # - https://github.com/travis-ci/travis-ci/issues/1147 # - https://docs.travis-ci.com/user/pull-requests/#double-builds-on-pull-requests -# branches: -# only: -# - master -# - /^\d+\.\d+(\.\d+)?(-\S*)?$/ +branches: + only: + - master + - /^\d+\.\d+(\.\d+)?(-\S*)?$/ cache: bundler: true @@ -28,16 +28,16 @@ stages: - test - deploy before_install: + # Launch a Jira instance in detached mode, will take a while, so do first + - docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone - pip install --upgrade tox tox-venv - rm -rf .tox -# Launch a Jira instance in detached mode - - docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone -# Wait until Jira has booted - - until $(curl -u $CI_JIRA_ADMIN:$CI_JIRA_ADMIN_PASSWORD --output /dev/null --silent --head --fail $CI_JIRA_URL/rest/api/2/permissions); do sleep 5; done -# Install this repository, as we will need it in the next step + # Install this repository, as we will need it in the next step - pip install . -# Attempt to create the test user, as the empty JIRA instance isn't provisioned with one - - (python -c "from jira import JIRA; JIRA('$CI_JIRA_URL', basic_auth=('$CI_JIRA_ADMIN', '$CI_JIRA_ADMIN_PASSWORD')).add_user('$CI_JIRA_USER', 'user@example.com', password='$CI_JIRA_USER_PASSWORD')" && echo "Created user '$CI_JIRA_USER'") || (echo "Failed creating user '$CI_JIRA_USER'" && docker logs --tail 500 jira) + # Wait until Jira has booted + - until $(curl -u $CI_JIRA_ADMIN:$CI_JIRA_ADMIN_PASSWORD --output /dev/null --silent --head --fail $CI_JIRA_URL/rest/api/2/permissions); do sleep 5; done + # Attempt to create the test user, as the empty JIRA instance isn't provisioned with one + - (python make_local_jira_user.py && echo "Created user '$CI_JIRA_USER'") || (echo "Failed creating user '$CI_JIRA_USER'" && docker logs --tail 500 jira) notifications: email: - pycontribs@googlegroups.com diff --git a/make_local_jira_user.py b/make_local_jira_user.py index 8f060e086..98a159c83 100644 --- a/make_local_jira_user.py +++ b/make_local_jira_user.py @@ -1,3 +1,6 @@ +"""Attempts to create a test user, +as the empty JIRA instance isn't provisioned with one. +""" from jira import JIRA from os import environ @@ -12,5 +15,5 @@ password=environ["CI_JIRA_USER_PASSWORD"], ) except Exception as e: - if not "username already exists" in str(e): + if "username already exists" not in str(e): raise e diff --git a/test.local b/test.local index 9c3ea7cbb..0c2f2c174 100755 --- a/test.local +++ b/test.local @@ -13,7 +13,7 @@ start_time="$(date -u +%s)" until $(curl -u $CI_JIRA_ADMIN:$CI_JIRA_ADMIN_PASSWORD --output /dev/null --silent --head --fail $CI_JIRA_URL/rest/api/2/permissions); do end_time="$(date -u +%s)";elapsed="$(($end_time-$start_time))"; echo "not running yet after $elapsed seconds $CI_JIRA_URL";sleep 5; done pip install -e . echo adding user $CI_JIRA_USER to $CI_JIRA_URL -(python -c "from jira import JIRA;jira_client = JIRA('$CI_JIRA_URL', basic_auth=('$CI_JIRA_ADMIN', '$CI_JIRA_ADMIN_PASSWORD'));a = jira_client.add_user('$CI_JIRA_USER', 'user@example.com', password='$CI_JIRA_USER_PASSWORD') if not jira_client.user('$CI_JIRA_USER') else None" && echo "Created user '$CI_JIRA_USER', or user was already present") || (echo "Failed creating user '$CI_JIRA_USER'" && docker logs --tail 500 jira) +(python make_local_jira_user.py && echo "Created user '$CI_JIRA_USER', or user was already present") || (echo "Failed creating user '$CI_JIRA_USER'" && docker logs --tail 500 jira) if [ "$1" = "--tox" ] ; then shift From 8a621c02e5f465816b2bf8f43666ea56a96c8d20 Mon Sep 17 00:00:00 2001 From: "Neefs, Jef" Date: Sun, 25 Apr 2021 18:29:50 +0200 Subject: [PATCH 010/103] using GH Actions --- .../workflows/jira_ci_python_test_runner.yml | 34 +++++++++++++++++++ setup.cfg | 1 + tox.ini | 8 +++++ 3 files changed, 43 insertions(+) create mode 100644 .github/workflows/jira_ci_python_test_runner.yml diff --git a/.github/workflows/jira_ci_python_test_runner.yml b/.github/workflows/jira_ci_python_test_runner.yml new file mode 100644 index 000000000..ef3c546f3 --- /dev/null +++ b/.github/workflows/jira_ci_python_test_runner.yml @@ -0,0 +1,34 @@ +name: Jira CI Python Test runner + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.6, 3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@master + - name: Start Jira docker instance + run: docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + - name: Test with tox + run: | + python -m tox + env: + CI_JIRA_URL: http://localhost:2990/jira + CI_JIRA_ADMIN: admin + CI_JIRA_ADMIN_PASSWORD: admin + CI_JIRA_USER: jira_user + CI_JIRA_USER_PASSWORD: jira diff --git a/setup.cfg b/setup.cfg index 22d6065bc..5f43b2946 100644 --- a/setup.cfg +++ b/setup.cfg @@ -74,6 +74,7 @@ opt = async = requests-futures>=0.9.7 test = + pytest docutils>=0.12 flaky MarkupSafe>=0.23 diff --git a/tox.ini b/tox.ini index 2a57554c8..cbdcbf3dd 100644 --- a/tox.ini +++ b/tox.ini @@ -121,3 +121,11 @@ deps = {[testenv:pkg]deps} commands = {[testenv:pkg]commands} twine upload dist/* + +[gh-actions] +python = + 3.5: py35 + 3.6: py36 + 3.7: py37 + 3.8: py38, mypy + 3.9: py39 \ No newline at end of file From a7c66d45fefc26ab82e6116508c917b13034b7ab Mon Sep 17 00:00:00 2001 From: "Neefs, Jef" Date: Mon, 26 Apr 2021 09:09:59 +0200 Subject: [PATCH 011/103] fixing kerberos install --- .github/workflows/jira_ci_python_test_runner.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/jira_ci_python_test_runner.yml b/.github/workflows/jira_ci_python_test_runner.yml index ef3c546f3..55ecadca8 100644 --- a/.github/workflows/jira_ci_python_test_runner.yml +++ b/.github/workflows/jira_ci_python_test_runner.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.9] steps: - uses: actions/checkout@master @@ -21,6 +21,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install Dependencies run: | + sudo apt-get install gcc python-dev libkrb5-dev python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Test with tox From 17e67be8e4452ecc192415614f6f7b3eb8808155 Mon Sep 17 00:00:00 2001 From: "Neefs, Jef" Date: Mon, 26 Apr 2021 09:19:00 +0200 Subject: [PATCH 012/103] waiting for docker --- .../workflows/jira_ci_python_test_runner.yml | 14 ++++- make_local_jira_user.py | 57 +++++++++++++++---- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/.github/workflows/jira_ci_python_test_runner.yml b/.github/workflows/jira_ci_python_test_runner.yml index 55ecadca8..96ef79f86 100644 --- a/.github/workflows/jira_ci_python_test_runner.yml +++ b/.github/workflows/jira_ci_python_test_runner.yml @@ -3,8 +3,8 @@ name: Jira CI Python Test runner on: [push, pull_request] jobs: - build: - + test: + name: ${{ matrix.os }} / ${{ matrix.python-version }} runs-on: ubuntu-latest strategy: matrix: @@ -14,6 +14,7 @@ jobs: - uses: actions/checkout@master - name: Start Jira docker instance run: docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone + - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 @@ -21,7 +22,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install Dependencies run: | - sudo apt-get install gcc python-dev libkrb5-dev + sudo apt-get update; sudo apt-get install gcc libkrb5-dev python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Test with tox @@ -33,3 +34,10 @@ jobs: CI_JIRA_ADMIN_PASSWORD: admin CI_JIRA_USER: jira_user CI_JIRA_USER_PASSWORD: jira + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1.0.15 + with: + file: ./coverage.xml + name: ${{ runner.os }}-${{ matrix.python-version }} + - name: Run tox pkg + run: tox -e pkg \ No newline at end of file diff --git a/make_local_jira_user.py b/make_local_jira_user.py index 98a159c83..084c2db96 100644 --- a/make_local_jira_user.py +++ b/make_local_jira_user.py @@ -1,19 +1,52 @@ -"""Attempts to create a test user, +"""Attempts to create a test user, as the empty JIRA instance isn't provisioned with one. """ +import time + +import requests + from jira import JIRA from os import environ +CI_JIRA_URL = environ["CI_JIRA_URL"] + + +def add_user_to_jira(): + try: + JIRA( + CI_JIRA_URL, + basic_auth=(environ["CI_JIRA_ADMIN"], environ["CI_JIRA_ADMIN_PASSWORD"]), + ).add_user( + environ["CI_JIRA_USER"], + "user@example.com", + password=environ["CI_JIRA_USER_PASSWORD"], + ) + print("user {}".format(environ["CI_JIRA_USER"])) + except Exception as e: + if "username already exists" not in str(e): + raise e + -try: - JIRA( - environ["CI_JIRA_URL"], - basic_auth=(environ["CI_JIRA_ADMIN"], environ["CI_JIRA_ADMIN_PASSWORD"]), - ).add_user( - environ["CI_JIRA_USER"], - "user@example.com", - password=environ["CI_JIRA_USER_PASSWORD"], +if __name__ == "__main__": + start_time = time.time() + timeout_mins = 15 + print( + "waiting for instance of jira to be running, to add a user for CI system:\n timeout = {} mins".format( + timeout_mins + ) ) -except Exception as e: - if "username already exists" not in str(e): - raise e + while True: + try: + requests.get(CI_JIRA_URL + "rest/api/2/permissions") + print("JIRA IS REACHABLE") + add_user_to_jira() + break + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as ex: + print( + "encountered {} while waiting for the JiraServer docker".format(str(ex)) + ) + time.sleep(20) + if start_time + 60 * timeout_mins < time.time(): + raise TimeoutError( + "Jira server wasn't reachable within timeout {}".format(timeout_mins) + ) From 5e1dd299b25fa3dc97e6c440466b405fb42c98dd Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Fri, 30 Apr 2021 22:45:34 +0100 Subject: [PATCH 013/103] satisfy lint, split ci into lint vs test sections, remove lint,pkg and docs for default tox envlist --- .../workflows/jira_ci_python_test_runner.yml | 66 +++++++++---------- .pre-commit-config.yaml | 8 +-- cspell.json | 2 + tox.ini | 25 ++++--- 4 files changed, 48 insertions(+), 53 deletions(-) diff --git a/.github/workflows/jira_ci_python_test_runner.yml b/.github/workflows/jira_ci_python_test_runner.yml index 96ef79f86..17e405bd4 100644 --- a/.github/workflows/jira_ci_python_test_runner.yml +++ b/.github/workflows/jira_ci_python_test_runner.yml @@ -1,43 +1,43 @@ name: Jira CI Python Test runner -on: [push, pull_request] +on: [push, pull_request] jobs: test: name: ${{ matrix.os }} / ${{ matrix.python-version }} - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }}-latest strategy: matrix: - python-version: [3.9] + os: [Ubuntu] + python-version: [3.8, 3.9] steps: - - uses: actions/checkout@master - - name: Start Jira docker instance - run: docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone - - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install Dependencies - run: | - sudo apt-get update; sudo apt-get install gcc libkrb5-dev - python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-gh-actions - - name: Test with tox - run: | - python -m tox - env: - CI_JIRA_URL: http://localhost:2990/jira - CI_JIRA_ADMIN: admin - CI_JIRA_ADMIN_PASSWORD: admin - CI_JIRA_USER: jira_user - CI_JIRA_USER_PASSWORD: jira - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1.0.15 - with: - file: ./coverage.xml - name: ${{ runner.os }}-${{ matrix.python-version }} - - name: Run tox pkg - run: tox -e pkg \ No newline at end of file + - uses: actions/checkout@master + - name: Start Jira docker instance + run: docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Dependencies + run: | + sudo apt-get update; sudo apt-get install gcc libkrb5-dev + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + + - name: Lint with tox + run: tox -e lint + + - name: Test with tox + run: tox + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1.0.15 + with: + file: ./coverage.xml + name: ${{ runner.os }}-${{ matrix.python-version }} + + - name: Run tox pkg + run: tox -e pkg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f59ad2b7..b12f1e46e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,18 +23,12 @@ repos: rev: 3.7.8 hooks: - id: flake8 - additional_dependencies: - - flake8-black - repo: https://github.com/adrienverge/yamllint.git rev: v1.17.0 hooks: - id: yamllint files: \.(yaml|yml)$ - - repo: https://github.com/openstack-dev/bashate.git - rev: 0.6.0 - hooks: - - id: bashate - repo: https://github.com/pre-commit/mirrors-mypy.git - rev: v0.730 + rev: v0.812 hooks: - id: mypy diff --git a/cspell.json b/cspell.json index 7bef05387..864a1f912 100644 --- a/cspell.json +++ b/cspell.json @@ -12,6 +12,7 @@ "bspeakmon", "capsys", "categorised", + "Codecov", "conda", "cygwin", "dae", @@ -66,6 +67,7 @@ "k", "ky", "kzh", + "libkrb", "lqqy", "luk", "makotemplate", diff --git a/tox.ini b/tox.ini index cbdcbf3dd..9ab729d98 100644 --- a/tox.ini +++ b/tox.ini @@ -3,17 +3,23 @@ minversion = 3.8.0 requires = tox-pyenv envlist = - lint - pkg + py39 py38 py37 py36 py35 - docs ignore_basepython_conflict = True skip_missing_interpreters = True skipdist = True +[gh-actions] +python = + 3.5: py35 + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39 + [testenv:docs] extras = docs @@ -69,7 +75,6 @@ passenv = PIP_* REQUESTS_CA_BUNDLE SSL_CERT_FILE - TRAVIS* TWINE_* XDG_CACHE_HOME # For Windows users, getpass.get_user() needs USERNAME @@ -80,6 +85,7 @@ envars = whitelist_externals = bash git + npm [testenv:pkg] deps = @@ -104,7 +110,8 @@ commands = [testenv:lint] deps = pre-commit>=1.17.0 commands= - bash -c "npm install && npm run spell" + npm install + npm run spell python -m pre_commit run --color=always {posargs:--all} extras = skip_install = true @@ -121,11 +128,3 @@ deps = {[testenv:pkg]deps} commands = {[testenv:pkg]commands} twine upload dist/* - -[gh-actions] -python = - 3.5: py35 - 3.6: py36 - 3.7: py37 - 3.8: py38, mypy - 3.9: py39 \ No newline at end of file From b45155a3c6cbb42ad42142a8f80279e7807fce5a Mon Sep 17 00:00:00 2001 From: studioj Date: Sat, 1 May 2021 16:45:29 +0200 Subject: [PATCH 014/103] adding all py versions and pip cache --- ...hon_test_runner.yml => jira_server_ci.yml} | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) rename .github/workflows/{jira_ci_python_test_runner.yml => jira_server_ci.yml} (61%) diff --git a/.github/workflows/jira_ci_python_test_runner.yml b/.github/workflows/jira_server_ci.yml similarity index 61% rename from .github/workflows/jira_ci_python_test_runner.yml rename to .github/workflows/jira_server_ci.yml index 17e405bd4..567c9f3d5 100644 --- a/.github/workflows/jira_ci_python_test_runner.yml +++ b/.github/workflows/jira_server_ci.yml @@ -1,4 +1,4 @@ -name: Jira CI Python Test runner +name: Jira Server CI on: [push, pull_request] @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [Ubuntu] - python-version: [3.8, 3.9] + python-version: [3.5, 3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@master @@ -21,6 +21,22 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + - name: Setup the Pip cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: >- + ${{ runner.os }}-pip-${{ hashFiles('setup.cfg') }}-${{ + hashFiles('setup.py') }}-${{ hashFiles('tox.ini') }}-${{ + hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + - name: Install Dependencies run: | sudo apt-get update; sudo apt-get install gcc libkrb5-dev @@ -41,3 +57,6 @@ jobs: - name: Run tox pkg run: tox -e pkg + + - name: Make docs + run: tox -e docs From 76f350bd54beca5110b16b7d206aef76a5aa524c Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 1 May 2021 00:26:37 +0200 Subject: [PATCH 015/103] fix docs build --- jira/client.py | 4 +++- tox.ini | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/jira/client.py b/jira/client.py index 53d53bd7e..79e1cf485 100644 --- a/jira/client.py +++ b/jira/client.py @@ -2585,10 +2585,12 @@ def statuses(self): return statuses def status(self, id): - # type: (str) -> Status """Get a status Resource from the server. :param id: ID of the status resource to get + :type id: str + + :rtype: Status """ return self._find_for_resource(Status, id) diff --git a/tox.ini b/tox.ini index 9ab729d98..41b22cedf 100644 --- a/tox.ini +++ b/tox.ini @@ -29,10 +29,8 @@ skipdist = False setenv = PYTHONHTTPSVERIFY=0 commands = - # pip install "..[docs]" - bash -c "set | grep REQUESTS_CA_BUNDLE" - python -m sphinx \ - -a -n -W \ + sphinx-build \ + -a -n -v -W --keep-going \ -b html --color \ -d "{toxworkdir}/docs_doctree" \ docs/ "{toxworkdir}/docs_out" From b68cb4a8cd9e9ec072c7b6e4f4654c7fa732d554 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 1 May 2021 12:09:46 +0100 Subject: [PATCH 016/103] bump lint versions --- .pre-commit-config.yaml | 8 ++++---- .yamllint | 17 ++++++++--------- jira/client.py | 8 ++++---- jira/exceptions.py | 2 +- jira/resources.py | 4 ++-- tests/tests.py | 6 +++--- 6 files changed, 22 insertions(+), 23 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b12f1e46e..4bfcb0db2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ --- repos: - repo: https://github.com/python/black - rev: 19.3b0 + rev: 21.4b2 hooks: - id: black language_version: python3 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v3.4.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace @@ -20,11 +20,11 @@ repos: - id: check-yaml files: .*\.(yaml|yml)$ - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.8 + rev: 3.9.1 hooks: - id: flake8 - repo: https://github.com/adrienverge/yamllint.git - rev: v1.17.0 + rev: v1.26.1 hooks: - id: yamllint files: \.(yaml|yml)$ diff --git a/.yamllint b/.yamllint index 028d683b9..dcb54406e 100644 --- a/.yamllint +++ b/.yamllint @@ -2,15 +2,15 @@ extends: default rules: - braces: {max-spaces-inside: 1, level: error} - brackets: {max-spaces-inside: 1, level: error} - colons: {max-spaces-after: -1, level: error} - commas: {max-spaces-after: -1, level: error} + braces: { max-spaces-inside: 1, level: error } + brackets: { max-spaces-inside: 1, level: error } + colons: { max-spaces-after: -1, level: error } + commas: { max-spaces-after: -1, level: error } comments: disable comments-indentation: disable document-start: disable - empty-lines: {max: 3, level: error} - hyphens: {level: error} + empty-lines: { max: 3, level: error } + hyphens: { level: error } indentation: indent-sequences: consistent # spaces: consistent @@ -23,9 +23,8 @@ rules: allow-non-breakable-words: true allow-non-breakable-inline-mappings: true new-line-at-end-of-file: disable - new-lines: {type: unix} + new-lines: disable trailing-spaces: disable truthy: disable -ignore: - .tox +ignore: .tox diff --git a/jira/client.py b/jira/client.py index 79e1cf485..8e0f30d63 100644 --- a/jira/client.py +++ b/jira/client.py @@ -2145,7 +2145,7 @@ def issue_type_by_name(self, name): return issue_type def request_types(self, service_desk): - """ Returns request types supported by a service desk instance. + """Returns request types supported by a service desk instance. :param service_desk: The service desk instance. :type service_desk: ServiceDesk :rtype: List[RequestType] @@ -3009,7 +3009,7 @@ def kill_websudo(self): # Utilities def _create_http_basic_session(self, username, password, timeout=None): - """ Creates a basic http session. + """Creates a basic http session. :param username: Username for the session :type username: str @@ -3099,7 +3099,7 @@ def _set_avatar(self, params, url, avatar): return self._session.put(url, params=params, data=json.dumps(data)) def _get_url(self, path, base=JIRA_BASE_URL): - """ Returns the full url based on Jira base url and the path provided. + """Returns the full url based on Jira base url and the path provided. Using the API version specified during the __init__. :param path: The subpath desired. @@ -3116,7 +3116,7 @@ def _get_url(self, path, base=JIRA_BASE_URL): return base.format(**options) def _get_latest_url(self, path, base=JIRA_BASE_URL): - """ Returns the full url based on Jira base url and the path provided. + """Returns the full url based on Jira base url and the path provided. Using the latest API endpoint. :param path: The subpath desired. diff --git a/jira/exceptions.py b/jira/exceptions.py index 6e1b4ee77..c1be44868 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -19,7 +19,7 @@ def __init__( response=None, **kwargs ): - """ Creates a JIRAError. + """Creates a JIRAError. :param status_code: Status code for the error. :type status_code: Optional[int] diff --git a/jira/resources.py b/jira/resources.py index 10d05130c..3725e4320 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -235,7 +235,7 @@ def find(self, id, params=None): self._load(url, params=params) def _get_url(self, path): - """ Gets the url for the specified path. + """Gets the url for the specified path. :type path: str @@ -377,7 +377,7 @@ def delete(self, params=None): return self._session.delete(url=self.self, params=params) def _load(self, url, headers=CaseInsensitiveDict(), params=None, path=None): - """ Load a resource. + """Load a resource. :type url: str :type headers: CaseInsensitiveDict diff --git a/tests/tests.py b/tests/tests.py index 774798f42..d3ddcebbe 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -2125,7 +2125,7 @@ def setUp(self): ) def test_fetch_pages(self): - """Tests that the JIRA._fetch_pages method works as expected. """ + """Tests that the JIRA._fetch_pages method works as expected.""" params = {"startAt": 0} total = 26 expected_results = [] @@ -2159,7 +2159,7 @@ def test_fetch_pages(self): def _create_issue_result_json(issue_id, summary, key, **kwargs): - """Returns a minimal json object for an issue. """ + """Returns a minimal json object for an issue.""" return { "id": "%s" % issue_id, "summary": summary, @@ -2169,7 +2169,7 @@ def _create_issue_result_json(issue_id, summary, key, **kwargs): def _create_issue_search_results_json(issues, **kwargs): - """Returns a minimal json object for Jira issue search results. """ + """Returns a minimal json object for Jira issue search results.""" return { "startAt": kwargs.get("start_at", 0), "maxResults": kwargs.get("max_results", 50), From 313f8c3bb4ac7e4e589384d742618339716cac4a Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 1 May 2021 21:26:39 +0100 Subject: [PATCH 017/103] commit package lock file https://nodejs.dev/learn/the-package-lock-json-file --- package-lock.json | 661 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..6e67bf45e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,661 @@ +{ + "name": "python-jira", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@cspell/dict-aws": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-1.0.14.tgz", + "integrity": "sha512-K21CfB4ZpKYwwDQiPfic2zJA/uxkbsd4IQGejEvDAhE3z8wBs6g6BwwqdVO767M9NgZqc021yAVpr79N5pWe3w==" + }, + "@cspell/dict-bash": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-1.0.12.tgz", + "integrity": "sha512-BOMHVW/m281mqUSJkZ3oiJiUUItLd7QdzpMjm428V9yBYFwIdbds1CeatS7C6kgpI2eBE4RXmy1Hjk/lR63Jew==" + }, + "@cspell/dict-companies": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-1.0.36.tgz", + "integrity": "sha512-Bk9mMJs9spzrtLxZsxBZIK6ukD9REfQYpuTBNJk/IiTViHVQ6ertHAgw1vRVtJAMxViv8dMLNtDyTpEXeaYm7w==" + }, + "@cspell/dict-cpp": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-1.1.38.tgz", + "integrity": "sha512-QqVMxVNYX9XtxzflpJ/888GSyjPU5VeotltsHql1BeEPxhyV27ud9bRKDrBGzCijCK/+MvCxiMZGDpYZqHTjXw==" + }, + "@cspell/dict-cryptocurrencies": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-1.0.10.tgz", + "integrity": "sha512-47ABvDJOkaST/rXipNMfNvneHUzASvmL6K/CbOFpYKfsd0x23Jc9k1yaOC7JAm82XSC/8a7+3Yu+Fk2jVJNnsA==" + }, + "@cspell/dict-csharp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-1.0.11.tgz", + "integrity": "sha512-nub+ZCiTgmT87O+swI+FIAzNwaZPWUGckJU4GN402wBq420V+F4ZFqNV7dVALJrGaWH7LvADRtJxi6cZVHJKeA==" + }, + "@cspell/dict-css": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-1.0.11.tgz", + "integrity": "sha512-2Or5oF5ojaXYD8QbO4Z+QdaNXSp+ZyNLJdeyKfejbxLvpL5feSNB0oYtTNrweFPTAvJKQ4DJsdEXy0/s31haRg==" + }, + "@cspell/dict-django": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-1.0.26.tgz", + "integrity": "sha512-mn9bd7Et1L2zuibc08GVHTiD2Go3/hdjyX5KLukXDklBkq06r+tb0OtKtf1zKodtFDTIaYekGADhNhA6AnKLkg==" + }, + "@cspell/dict-dotnet": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-1.0.25.tgz", + "integrity": "sha512-3BFhdquYqqjeI8Jm1dYepZKGEg+fKFhw7UfPkVdx13C4ETo5VlsS4FAblC0pCY21pDU3QgRZOGL1Bj+KWCGp/w==" + }, + "@cspell/dict-elixir": { + "version": "1.0.24", + "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-1.0.24.tgz", + "integrity": "sha512-pEX6GYlEx4Teusw/m+XmqoXzcHOqpcn1ZX4H33ONqR81XdPwbaKorBr1IG23Ic76IhwrFlOqs48tcnxrHYpFnA==" + }, + "@cspell/dict-en-gb": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb/-/dict-en-gb-1.1.28.tgz", + "integrity": "sha512-noOH+iv4xFpPxu1agiQgp5LhY/KA0Ir28y1xnC2QTtLvlIid7vIvgixBOz4Zi0P7lo/mPmMjQY+x7//2EKFDgQ==" + }, + "@cspell/dict-en_us": { + "version": "1.2.40", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-1.2.40.tgz", + "integrity": "sha512-e8leCvGAWPWQIw0SoozgEAiMt2YM12rafOuW4aQwgTJD++vp32a9RrnVL8olBfWaA57rRWWndbMSmPTrsO9mpg==" + }, + "@cspell/dict-filetypes": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-1.1.5.tgz", + "integrity": "sha512-yfkB37J+hL6W8qa4AknFp7u6CGECrw2ql2/y0lUKruLQYid0ApK+bH+ll+Sqgl2YS5QAOhclskc72aQHAcRJIQ==" + }, + "@cspell/dict-fonts": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-1.0.14.tgz", + "integrity": "sha512-VhIX+FVYAnqQrOuoFEtya6+H72J82cIicz9QddgknsTqZQ3dvgp6lmVnsQXPM3EnzA8n1peTGpLDwHzT7ociLA==" + }, + "@cspell/dict-fullstack": { + "version": "1.0.37", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-1.0.37.tgz", + "integrity": "sha512-ljVzUdIlBENMiyHUV06007hz2FPRt+BQmC9Jgn6iGIEQeAQp37Q6oIDmxv2lD65ScEIbysxXuaUgJ5x0j4a48A==" + }, + "@cspell/dict-golang": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-1.1.24.tgz", + "integrity": "sha512-qq3Cjnx2U1jpeWAGJL1GL0ylEhUMqyaR36Xij6Y6Aq4bViCRp+HRRqk0x5/IHHbOrti45h3yy7ii1itRFo+Xkg==" + }, + "@cspell/dict-haskell": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-1.0.13.tgz", + "integrity": "sha512-kvl8T84cnYRPpND/P3D86P6WRSqebsbk0FnMfy27zo15L5MLAb3d3MOiT1kW3vEWfQgzUD7uddX/vUiuroQ8TA==" + }, + "@cspell/dict-html": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-1.1.6.tgz", + "integrity": "sha512-RsZXIrmsnLcUpXfyZdNg7OtO2+e4p7m/qILg03kM6vhSUMY6ryCQNPWKrHqsl8+LBKd54EgFM+O5zcgq6IIsCw==" + }, + "@cspell/dict-html-symbol-entities": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-1.0.23.tgz", + "integrity": "sha512-PV0UBgcBFbBLf/m1wfkVMM8w96kvfHoiCGLWO6BR3Q9v70IXoE4ae0+T+f0CkxcEkacMqEQk/I7vuE9MzrjaNw==" + }, + "@cspell/dict-java": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-1.0.22.tgz", + "integrity": "sha512-CVAJ29dx1XwwutgsMgaj5eCl1Nc7X7qFhWL2KkAdu78A/NUIaS+1I9KS0hHhdZx/wLke9dH8TR7NyPQGpGxeAw==" + }, + "@cspell/dict-latex": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-1.0.25.tgz", + "integrity": "sha512-cEgg91Migqcp1SdVV7dUeMxbPDhxdNo6Fgq2eygAXQjIOFK520FFvh/qxyBvW90qdZbIRoU2AJpchyHfGuwZFA==" + }, + "@cspell/dict-lorem-ipsum": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-1.0.22.tgz", + "integrity": "sha512-yqzspR+2ADeAGUxLTfZ4pXvPl7FmkENMRcGDECmddkOiuEwBCWMZdMP5fng9B0Q6j91hQ8w9CLvJKBz10TqNYg==" + }, + "@cspell/dict-lua": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-1.0.16.tgz", + "integrity": "sha512-YiHDt8kmHJ8nSBy0tHzaxiuitYp+oJ66ffCYuFWTNB3//Y0SI4OGHU3omLsQVeXIfCeVrO4DrVvRDoCls9B5zQ==" + }, + "@cspell/dict-node": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-1.0.11.tgz", + "integrity": "sha512-q66zAqtNmuvZGKt4stRwQPFLsbOjZGGZOZ1HEbqpOkicxvF0BWhR0Di/JBq27PDxeqQP3S5sLeogQTSNQBuTww==" + }, + "@cspell/dict-npm": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-1.0.11.tgz", + "integrity": "sha512-mokmv9/Yk1yliDz97drWyuDWv7eKGEcFhdM43YSPK7GuMLh6i2ULOmORPFhUcjxQjPf0uySMDA2JguiQ4m5Lmg==" + }, + "@cspell/dict-php": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-1.0.23.tgz", + "integrity": "sha512-rRLf/09rXDrzs0DJuNXNmFVTw2b2zLmZKNF4LIPrFHYHvdfsMvwVqxkr/SAyhF8C6zi5sW0XYC/J0S/3IE927w==" + }, + "@cspell/dict-powershell": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-1.0.14.tgz", + "integrity": "sha512-hisOXXi5PBXB5YKtrJQIis2FIRHgSW1U0/sd4yI36lzb3ZMEvGJwdAdyhXN3IGiqRUNxMzJiXAeXfhnia4xPtQ==" + }, + "@cspell/dict-python": { + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-1.0.33.tgz", + "integrity": "sha512-tRmE4TzHDFPs7sJ1a3XbfyFrvRHwefVz+z1wkm6tkXK9TPrCbIS+rV/T8xhj205q4lpZQ/TkNB3lT40eLB9O8A==" + }, + "@cspell/dict-ruby": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-1.0.13.tgz", + "integrity": "sha512-YeN1acY38dgMYlEJ6iWPH+8qXB6seLKHm9BszXxaKT/IzGA9Y9XUWPGobeJFD5E/tC6HjvcqRKxEs8vnvakoLQ==" + }, + "@cspell/dict-rust": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-1.0.22.tgz", + "integrity": "sha512-7WOIzv0BPiU+MssZbbMk8K+HR/g9Bcvd0+jXJC3/AKT8L6l0Mx0Tr/oF7cJ4xvCYgA84nBz3PhMZkabGSz/Nkg==" + }, + "@cspell/dict-scala": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-1.0.21.tgz", + "integrity": "sha512-5V/R7PRbbminTpPS3ywgdAalI9BHzcEjEj9ug4kWYvBIGwSnS7T6QCFCiu+e9LvEGUqQC+NHgLY4zs1NaBj2vA==" + }, + "@cspell/dict-software-terms": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-1.0.27.tgz", + "integrity": "sha512-O6wCGuFSnr9G9Sr62zc7/XyruRRPI0/PJ0xZj8/R+hr+vFjDaScQnkqj10gTVoLAshk1TjL5Firnzyz3ibfgdQ==" + }, + "@cspell/dict-typescript": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-1.0.17.tgz", + "integrity": "sha512-CXCuXcrgAc56P3kL9I6gW6bZwTs6t3duyAtHerHg5YAYbPs6/4nXgniQgLgu8kjFHFy07XrqaaBdLU9V2DmMtQ==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==" + }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "requires": { + "fill-range": "^7.0.1" + } + }, + "chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" + }, + "comment-json": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.1.0.tgz", + "integrity": "sha512-WEghmVYaNq9NlWbrkzQTSsya9ycLyxJxpTQfZEan6a5Jomnjw18zS3Podf8q1Zf9BvonvQd/+Z7Z39L7KKzzdQ==", + "requires": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.2", + "esprima": "^4.0.1", + "has-own-prop": "^2.0.0", + "repeat-string": "^1.6.1" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "requires": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" + }, + "cspell": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-4.2.8.tgz", + "integrity": "sha512-eqan8+lCU9bSp8Tl4+SR/ccBnuPyMmp7evck/RlMdFTjLh/s+3vQ5hQyBzbzK8w2MMqL84CymW7BwIOKjpylSg==", + "requires": { + "chalk": "^4.1.0", + "commander": "^7.0.0", + "comment-json": "^4.0.6", + "cspell-glob": "^0.1.25", + "cspell-lib": "^4.3.12", + "fs-extra": "^9.1.0", + "gensequence": "^3.1.1", + "get-stdin": "^8.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + } + }, + "cspell-glob": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-0.1.25.tgz", + "integrity": "sha512-/XaSHrGBpMJa+duFz3GKOWfrijrfdHT7a/XGgIcq3cymCSpOH+DPho42sl0jLI/hjM+8yv2m8aEoxRT8yVSnlg==", + "requires": { + "micromatch": "^4.0.2" + } + }, + "cspell-io": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-4.1.7.tgz", + "integrity": "sha512-V0/tUu9FnIS3v+vAvDT6NNa14Nc/zUNX8+YUUOfFAiDJJTdqefmvcWjOJBIMYBf3wIk9iWLmLbMM+bNHqr7DSQ==", + "requires": { + "iconv-lite": "^0.6.2", + "iterable-to-stream": "^1.0.1" + } + }, + "cspell-lib": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-4.3.12.tgz", + "integrity": "sha512-yCCb6MoW1K8Tsr/WVEQoO4dfYhH9bCsjQayccb8MlyDaNNuWJHuX+gUGHsZSXSuChSh8PrTWKXJzs13/uM977g==", + "requires": { + "@cspell/dict-aws": "^1.0.13", + "@cspell/dict-bash": "^1.0.11", + "@cspell/dict-companies": "^1.0.35", + "@cspell/dict-cpp": "^1.1.37", + "@cspell/dict-cryptocurrencies": "^1.0.10", + "@cspell/dict-csharp": "^1.0.10", + "@cspell/dict-css": "^1.0.10", + "@cspell/dict-django": "^1.0.25", + "@cspell/dict-dotnet": "^1.0.24", + "@cspell/dict-elixir": "^1.0.23", + "@cspell/dict-en-gb": "^1.1.27", + "@cspell/dict-en_us": "^1.2.39", + "@cspell/dict-filetypes": "^1.1.5", + "@cspell/dict-fonts": "^1.0.13", + "@cspell/dict-fullstack": "^1.0.36", + "@cspell/dict-golang": "^1.1.24", + "@cspell/dict-haskell": "^1.0.12", + "@cspell/dict-html": "^1.1.5", + "@cspell/dict-html-symbol-entities": "^1.0.23", + "@cspell/dict-java": "^1.0.22", + "@cspell/dict-latex": "^1.0.23", + "@cspell/dict-lorem-ipsum": "^1.0.22", + "@cspell/dict-lua": "^1.0.16", + "@cspell/dict-node": "^1.0.10", + "@cspell/dict-npm": "^1.0.10", + "@cspell/dict-php": "^1.0.23", + "@cspell/dict-powershell": "^1.0.14", + "@cspell/dict-python": "^1.0.32", + "@cspell/dict-ruby": "^1.0.12", + "@cspell/dict-rust": "^1.0.22", + "@cspell/dict-scala": "^1.0.21", + "@cspell/dict-software-terms": "^1.0.24", + "@cspell/dict-typescript": "^1.0.16", + "comment-json": "^4.1.0", + "configstore": "^5.0.1", + "cspell-io": "^4.1.7", + "cspell-trie-lib": "^4.2.8", + "cspell-util-bundle": "^4.1.11", + "fs-extra": "^9.1.0", + "gensequence": "^3.1.1", + "minimatch": "^3.0.4", + "resolve-from": "^5.0.0", + "resolve-global": "^1.0.0", + "vscode-uri": "^3.0.2" + } + }, + "cspell-trie-lib": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-4.2.8.tgz", + "integrity": "sha512-Nt3c0gxOYXIc3/yhALDukpje1BgR6guvlUKWQO2zb0r7qRWpwUw2j2YM4dWbHQeH/3Hx5ei4Braa6cMaiJ5YBw==", + "requires": { + "gensequence": "^3.1.1" + } + }, + "cspell-util-bundle": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/cspell-util-bundle/-/cspell-util-bundle-4.1.11.tgz", + "integrity": "sha512-or3OGKydZs1NwweMIgnA48k8H3F5zK4e5lonjUhpEzLYQZ2nB23decdoqZ8ogFC8pFTA40tZKDsMJ0b+65gX4Q==" + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "requires": { + "is-obj": "^2.0.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "gensequence": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-3.1.1.tgz", + "integrity": "sha512-ys3h0hiteRwmY6BsvSttPmkhC0vEQHPJduANBRtH/dlDPZ0UBIb/dXy80IcckXyuQ6LKg+PloRqvGER9IS7F7g==" + }, + "get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==" + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "global-dirs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", + "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", + "requires": { + "ini": "^1.3.4" + } + }, + "graceful-fs": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "has-own-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", + "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==" + }, + "iconv-lite": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", + "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "iterable-to-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/iterable-to-stream/-/iterable-to-stream-1.0.1.tgz", + "integrity": "sha512-O62gD5ADMUGtJoOoM9U6LQ7i4byPXUNoHJ6mqsmkQJcom331ZJGDApWgDESWyBMEHEJRjtHozgIiTzYo9RU4UA==" + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + } + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "picomatch": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz", + "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==" + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" + }, + "resolve-global": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", + "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", + "requires": { + "global-dirs": "^0.1.1" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "requires": { + "crypto-random-string": "^2.0.0" + } + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" + }, + "vscode-uri": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.2.tgz", + "integrity": "sha512-jkjy6pjU1fxUvI51P+gCsxg1u2n8LSt0W6KrCNQceaziKzff74GoWmjVG46KieVzybO1sttPQmYfrwSHey7GUA==" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" + } + } +} From 1f36487d1f3ba2f69de4db45df30ad9f14259a32 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 1 May 2021 21:43:09 +0100 Subject: [PATCH 018/103] avoid duplicate builds running ? --- .github/workflows/jira_server_ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/jira_server_ci.yml b/.github/workflows/jira_server_ci.yml index 567c9f3d5..b767b75f3 100644 --- a/.github/workflows/jira_server_ci.yml +++ b/.github/workflows/jira_server_ci.yml @@ -1,6 +1,14 @@ name: Jira Server CI -on: [push, pull_request] +on: + # Trigger the workflow on push or pull request, + # but only for the master branch + push: + branches: + - master + pull_request: + branches: + - master jobs: test: From 2ea7e8fad5f95a4a71e3cd014aa1bf664526c70b Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 1 May 2021 21:47:25 +0100 Subject: [PATCH 019/103] update developer instructions --- README.rst | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index 3d9ea4f82..e09eda268 100644 --- a/README.rst +++ b/README.rst @@ -102,21 +102,17 @@ Setup * Fork_ repo * Keep it sync_'ed while you are developing * Install pyenv_ -* develop and test, old way - * Install `Atlassian JIRA Server`_ for testing - - make install-sdk - * pip install jira[test] - * Start up Jira Server - - atlas-run-standalone - * Test your changes - - make test -* develop and test new way => at least to be able to fix the CI +* develop and test * launch docker jira server - docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone + * lint + - tox -e lint * run tests - - ./test_local --tox + - tox * run tests for one env only - - ./test_local --tox -e py37 + - tox -e py37 + * Build and publish with TWINE + - tox -e upload .. _Fork: https://help.github.com/articles/fork-a-repo/ .. _sync: https://help.github.com/articles/syncing-a-fork/ From 3483a7c7d65838e6d3b9d5bcc3fa6fa8d1c58042 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 1 May 2021 21:47:34 +0100 Subject: [PATCH 020/103] clean legacy --- .travis.yml | 90 ----------------------------------------------------- setup.cfg | 1 - test.local | 23 -------------- tox.ini | 1 - 4 files changed, 115 deletions(-) delete mode 100644 .travis.yml delete mode 100755 test.local diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9c6f24a5c..000000000 --- a/.travis.yml +++ /dev/null @@ -1,90 +0,0 @@ ---- -language: python -dist: xenial -services: - - docker -# Build only commits on master and release tags for the "Build pushed branches" feature. -# This prevents building twice on PRs originating from our repo ("Build pushed pull requests)". -# See: -# - https://github.com/travis-ci/travis-ci/issues/1147 -# - https://docs.travis-ci.com/user/pull-requests/#double-builds-on-pull-requests -branches: - only: - - master - - /^\d+\.\d+(\.\d+)?(-\S*)?$/ - -cache: - bundler: true - pip: true - directories: - - $HOME/.cache/pre-commit - - $HOME/.pre-commit - - $HOME/.rvm - - $HOME/Library/Caches/Homebrew -os: - - linux -stages: - - maintenance - - test - - deploy -before_install: - # Launch a Jira instance in detached mode, will take a while, so do first - - docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone - - pip install --upgrade tox tox-venv - - rm -rf .tox - # Install this repository, as we will need it in the next step - - pip install . - # Wait until Jira has booted - - until $(curl -u $CI_JIRA_ADMIN:$CI_JIRA_ADMIN_PASSWORD --output /dev/null --silent --head --fail $CI_JIRA_URL/rest/api/2/permissions); do sleep 5; done - # Attempt to create the test user, as the empty JIRA instance isn't provisioned with one - - (python make_local_jira_user.py && echo "Created user '$CI_JIRA_USER'") || (echo "Failed creating user '$CI_JIRA_USER'" && docker logs --tail 500 jira) -notifications: - email: - - pycontribs@googlegroups.com -jobs: - include: - - stage: maintenance - script: - - python -m tox -e maintenance - if: type = cron - - script: - - python -m tox - env: TOXENV="lint,docs,pkg,py37" - python: "3.7" - after_success: - - bash <(curl -s https://codecov.io/bash) -e py37 - - script: python -m tox - python: "3.6" - env: TOXENV=py36 PYTHON='3.6' - after_success: - - bash <(curl -s https://codecov.io/bash) -e TOXENV - - script: python -m tox - python: "3.5" - env: TOXENV=py35 - after_success: - - bash <(curl -s https://codecov.io/bash) -e TOXENV - - stage: deploy - script: - - tox -e upload - if: tag IS present AND type != cron - deploy: - - provider: releases - api_key: - secure: YJGigSNYOzMJqs23gIZLFxiVYRqHdV4WsTZmRVosishD2QIaDlTwJma7k6Y5eMPVNdLpqo7Tq6bt7xkJAz/dcr3UO35T/Y0tiRFFW3sd6IOB6ELwSwPhSeHoyUMvZtKyDTl+9tOfeZusFZuCc+mBLQcG+S2NzEaeyrQ6n5hTT/8FGBP91FOq9l5q2gYbmACZ9MisDIjZkTHNYih36ComnZ9QHC91jHKcSuHmOfWWX3GneDVFtuPhF2vjaLQrz8IFtWGW5Sfe35yDYlVQRH+NFxzSJ2zDuT5j8cRgwXjGout78umtMsqAn+zv1Ws/MUNKMTEtONsACndMpGCkuB6Nifl/KcGj5kD7V4PO/gE0ecr830qAwJxSVB7xk6rl797nMxGbr4w2DWQ/iDdHDTlPAEzbLBMLrMRgPxzKPgg5CNxxjT1cHoBNcFPp6gaf017w4XOVUgp/zxXeCg7iGiNJj7z2t8/m9eYVNNlNRPcodN6BjSjPqkYxC3ZMVCI5KsRXbHmR0zOWbPdcRjrY/IgbiTqX09sHotHw5GThP6YTMbienC4h93cdx6MEfX656W6XMOxpC+MjWtYuV8QlfMEJFlstOnA86MVLcmbl+4A6FHuvlQMdDtP9KsKdKIf/4juGhNEFir32P1rUe8J1abmjwXmDkHVbli0SDqaFtB5gyCc= - file_glob: true - file: - - dist/* - - ChangeLog - skip_cleanup: true - on: - tags: true - repo: pycontribs/jira - branch: master -env: - global: - - PIP_DISABLE_PIP_VERSION_CHECK=1 - - CI_JIRA_URL=http://localhost:2990/jira - - CI_JIRA_ADMIN=admin - - CI_JIRA_ADMIN_PASSWORD=admin - - CI_JIRA_USER=jira_user - - CI_JIRA_USER_PASSWORD=jira diff --git a/setup.cfg b/setup.cfg index 5f43b2946..22d6065bc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -74,7 +74,6 @@ opt = async = requests-futures>=0.9.7 test = - pytest docutils>=0.12 flaky MarkupSafe>=0.23 diff --git a/test.local b/test.local deleted file mode 100755 index 0c2f2c174..000000000 --- a/test.local +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -export CI_JIRA_URL="http://localhost:2990/jira" -export CI_JIRA_ADMIN="admin" -export CI_JIRA_ADMIN_PASSWORD="admin" -export CI_JIRA_USER=jira_user -export CI_JIRA_USER_PASSWORD=jira -export CI_JIRA_ISSUE=Task -#sudo docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone -echo waiting for the jira server to be up and running... -echo THIS CAN TAKE A WHILE on a vm this can take up to 10 minutes -start_time="$(date -u +%s)" -until $(curl -u $CI_JIRA_ADMIN:$CI_JIRA_ADMIN_PASSWORD --output /dev/null --silent --head --fail $CI_JIRA_URL/rest/api/2/permissions); do end_time="$(date -u +%s)";elapsed="$(($end_time-$start_time))"; echo "not running yet after $elapsed seconds $CI_JIRA_URL";sleep 5; done -pip install -e . -echo adding user $CI_JIRA_USER to $CI_JIRA_URL -(python make_local_jira_user.py && echo "Created user '$CI_JIRA_USER', or user was already present") || (echo "Failed creating user '$CI_JIRA_USER'" && docker logs --tail 500 jira) - -if [ "$1" = "--tox" ] ; then - shift - exec tox "$@" -else - exec python -m pytest --cov-report xml --cov jira --pyargs jira "$@" -fi diff --git a/tox.ini b/tox.ini index 41b22cedf..c8344b257 100644 --- a/tox.ini +++ b/tox.ini @@ -81,7 +81,6 @@ envars = PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_USER=no whitelist_externals = - bash git npm From c1d0af434b2778a48bbc15bf86d2c5dded013152 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 1 May 2021 22:36:16 +0100 Subject: [PATCH 021/103] fix lint, add guidance for Windows users --- MANIFEST.in | 8 ++++++++ README.rst | 56 ++++++++++++++++++++++++++++------------------------- cspell.json | 11 +++++++++-- tox.ini | 10 +++------- 4 files changed, 50 insertions(+), 35 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index f7afaafa0..3c0f8bf8a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,10 @@ include LICENSE README.rst + +# Exclude what is in these folders prune tests +prune .github + +# Exclude these files +exclude package-lock.json +recursive-exclude * *.py[co] +recursive-exclude * __pycache__ diff --git a/README.rst b/README.rst index e09eda268..5bcdb02a9 100644 --- a/README.rst +++ b/README.rst @@ -3,41 +3,41 @@ Jira Python Library =================== .. image:: https://img.shields.io/pypi/v/jira.svg - :target: https://pypi.python.org/pypi/jira/ + :target: https://pypi.python.org/pypi/jira/ .. image:: https://img.shields.io/pypi/l/jira.svg - :target: https://pypi.python.org/pypi/jira/ + :target: https://pypi.python.org/pypi/jira/ .. image:: https://img.shields.io/pypi/wheel/jira.svg - :target: https://pypi.python.org/pypi/jira/ + :target: https://pypi.python.org/pypi/jira/ .. image:: https://img.shields.io/github/issues/pycontribs/jira.svg - :target: https://github.com/pycontribs/jira/issues + :target: https://github.com/pycontribs/jira/issues .. image:: https://img.shields.io/badge/irc-%23pycontribs-blue - :target: irc:///#pycontribs + :target: irc:///#pycontribs ------------ .. image:: https://readthedocs.org/projects/jira/badge/?version=master - :target: https://jira.readthedocs.io/ + :target: https://jira.readthedocs.io/ .. image:: https://travis-ci.com/pycontribs/jira.svg?branch=master - :target: https://travis-ci.com/pycontribs/jira + :target: https://travis-ci.com/pycontribs/jira .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/python/black :alt: Python Black Code Style .. image:: https://codecov.io/gh/pycontribs/jira/branch/master/graph/badge.svg - :target: https://codecov.io/gh/pycontribs/jira + :target: https://codecov.io/gh/pycontribs/jira .. image:: https://img.shields.io/bountysource/team/pycontribs/activity.svg - :target: https://www.bountysource.com/teams/pycontribs/issues?tracker_ids=3650997 + :target: https://www.bountysource.com/teams/pycontribs/issues?tracker_ids=3650997 .. image:: https://requires.io/github/pycontribs/jira/requirements.svg?branch=master - :target: https://requires.io/github/pycontribs/jira/requirements/?branch=master - :alt: Requirements Status + :target: https://requires.io/github/pycontribs/jira/requirements/?branch=master + :alt: Requirements Status This library eases the use of the Jira REST API from Python and it has been used in production for years. @@ -54,14 +54,14 @@ Feeling impatient? I like your style. .. code-block:: python - from jira import JIRA + from jira import JIRA - jira = JIRA('https://jira.atlassian.com') + jira = JIRA('https://jira.atlassian.com') - issue = jira.issue('JRA-9') - print(issue.fields.project.key) # 'JRA' - print(issue.fields.issuetype.name) # 'New Feature' - print(issue.fields.reporter.displayName) # 'Mike Cannon-Brookes [Atlassian]' + issue = jira.issue('JRA-9') + print(issue.fields.project.key) # 'JRA' + print(issue.fields.issuetype.name) # 'New Feature' + print(issue.fields.reporter.displayName) # 'Mike Cannon-Brookes [Atlassian]' Installation @@ -103,16 +103,20 @@ Setup * Keep it sync_'ed while you are developing * Install pyenv_ * develop and test - * launch docker jira server - - docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone - * lint - - tox -e lint - * run tests - - tox - * run tests for one env only - - tox -e py37 + * Launch docker jira server + - ``docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone`` + * Lint + - ``tox -e lint`` + - Note: Windows users trying to run locally will need to: + - Comment out the ``npm`` commands in the ``lint`` environment before running the ``lint`` environment + - Run ``npm install`` manually + - Run ``cspell "**" --unique`` manually - this relies on the ``cspell.json`` to check the right files + * Run tests + - ``tox`` + * Run tests for one env only + - ``tox -e py37`` * Build and publish with TWINE - - tox -e upload + - ``tox -e upload`` .. _Fork: https://help.github.com/articles/fork-a-repo/ .. _sync: https://help.github.com/articles/syncing-a-fork/ diff --git a/cspell.json b/cspell.json index 864a1f912..18ff1b2a8 100644 --- a/cspell.json +++ b/cspell.json @@ -184,9 +184,16 @@ "/I18NSPHINXOPTS/" ], "ignorePaths": [ - "docs/build", + "__pycache__", + ".eggs", ".tox", - ".eggs" + "*.egg-info", + "*.egg", + "*.pyc", + "dist/**", + "docs/_build/**", + "node_modules/**", + "package-lock.json" ], "ignoreWords": [ "AACCOUNTID", diff --git a/tox.ini b/tox.ini index c8344b257..69542bbc6 100644 --- a/tox.ini +++ b/tox.ini @@ -87,7 +87,7 @@ whitelist_externals = [testenv:pkg] deps = collective.checkdocs>=0.2 - pep517>=0.7.0 + build>=0.3.0 pip>=19.2.3 setuptools>=41.4 twine>=2.0.0 @@ -97,12 +97,8 @@ commands = python setup.py check -m -s # disabled due to errors with older setuptools: # python setup.py sdist bdist_wheel - python -m pep517.build \ - --source \ - --binary \ - --out-dir {toxinidir}/dist/ \ - {toxinidir} - python -m twine check {toxinidir}/dist/* + python -m build --wheel --sdist . + python -m twine check dist/* [testenv:lint] deps = pre-commit>=1.17.0 From 60ec4a049be34e265ed7b9ff019246bbc94db422 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Fri, 30 Apr 2021 22:47:22 +0100 Subject: [PATCH 022/103] Upgrade to GitHub-native Dependabot fix wrong target branch --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5e06ad2f3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + target-branch: master From 7ad7178d8b51f9abf968e29a79cb196e5a77526b Mon Sep 17 00:00:00 2001 From: studioj Date: Sat, 1 May 2021 17:06:56 +0200 Subject: [PATCH 023/103] drop support for py 35 --- .github/workflows/jira_server_ci.yml | 2 +- Makefile | 3 +-- docs/contributing.rst | 2 +- docs/installation.rst | 2 +- pyproject.toml | 2 +- setup.cfg | 7 +++---- tox.ini | 2 -- 7 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/jira_server_ci.yml b/.github/workflows/jira_server_ci.yml index b767b75f3..400cbb1c7 100644 --- a/.github/workflows/jira_server_ci.yml +++ b/.github/workflows/jira_server_ci.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: os: [Ubuntu] - python-version: [3.5, 3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@master diff --git a/Makefile b/Makefile index b21499b7c..b19faa59e 100644 --- a/Makefile +++ b/Makefile @@ -72,10 +72,9 @@ dist: $(PREFIX)python setup.py sdist bdist_wheel prepare: - @pyenv install -s 3.5.7 @pyenv install -s 3.6.9 @pyenv install -s 3.7.4 - @pyenv local 3.5.7 3.6.9 3.7.4 + @pyenv local 3.6.9 3.7.4 @echo "INFO: === Preparing to run for package:$(PACKAGE_NAME) platform:$(PLATFORM) py:$(PYTHON_VERSION) dir:$(DIR) ===" #if [ -f ${HOME}/testspace/testspace ]; then ${HOME}/testspace/testspace config url ${TESTSPACE_TOKEN}@pycontribs.testspace.com/jira/tests ; fi; diff --git a/docs/contributing.rst b/docs/contributing.rst index 1c4e9e635..a07719933 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -63,7 +63,7 @@ Issues and Feature Requests * How to recreate the bug. * If relevant, including the versions of your: - * Python interpreter (3.5, etc) + * Python interpreter (3.6, etc) * jira-python * Operating System and Version (Windows 7, OS X 10.10, Ubuntu 14.04, etc.) * IPython if using jirashell diff --git a/docs/installation.rst b/docs/installation.rst index bd1f1bf61..766998e02 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -28,7 +28,7 @@ Source packages are also available at PyPI: Dependencies ============ -Python 3.5+ is required. +Python >3.5 is required. - :py:mod:`requests` - `python-requests `_ library handles the HTTP business. Usually, the latest version available at time of release is the minimum version required; at this writing, that version is 1.2.0, but any version >= 1.0.0 should work. - :py:mod:`requests-oauthlib` - Used to implement OAuth. The latest version as of this writing is 0.3.3. diff --git a/pyproject.toml b/pyproject.toml index ba41bc7f5..3b9e229bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,5 +5,5 @@ requires = [ "setuptools_scm_git_archive >= 1.0", "wheel", ] -requires-python = ">=3.5" +requires-python = ">3.5" build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg index 22d6065bc..a30330786 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,7 @@ project_urls = Source Code = https://github.com/pycontribs/jira.git Documentation = https://jira.readthedocs.io/en/master/ Forum = https://community.atlassian.com/t5/tag/jira-python/tg-p?sort=recent -requires_python = >=3.5 +requires_python = >3.5 platforms = any license = BSD classifiers = @@ -31,7 +31,6 @@ classifiers = Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Topic :: Software Development :: Libraries :: Python Modules @@ -44,7 +43,7 @@ packages = [options] use_scm_version = True -python_requires = >=3.5 +python_requires = >3.5 packages = find: include_package_data = True zip_safe = False @@ -146,4 +145,4 @@ filterwarnings = ignore::pytest.PytestWarning [mypy] -python_version = 3.5 +python_version = 3.6 diff --git a/tox.ini b/tox.ini index 69542bbc6..9b4945947 100644 --- a/tox.ini +++ b/tox.ini @@ -7,14 +7,12 @@ envlist = py38 py37 py36 - py35 ignore_basepython_conflict = True skip_missing_interpreters = True skipdist = True [gh-actions] python = - 3.5: py35 3.6: py36 3.7: py37 3.8: py38 From 0b470b34ffa33b092dde38918a0d3b509491b6dd Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Sun, 2 May 2021 07:58:17 +0100 Subject: [PATCH 024/103] Update setup.cfg Change-Id: I85004d9e7ace64eba4a2bf453a00856011f22c08 --- setup.cfg | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index a30330786..86169a151 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,7 @@ project_urls = Source Code = https://github.com/pycontribs/jira.git Documentation = https://jira.readthedocs.io/en/master/ Forum = https://community.atlassian.com/t5/tag/jira-python/tg-p?sort=recent -requires_python = >3.5 +requires_python = >=3.6 platforms = any license = BSD classifiers = @@ -33,6 +33,8 @@ classifiers = Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 Topic :: Software Development :: Libraries :: Python Modules Topic :: Internet :: WWW/HTTP keywords = api, atlassian, jira, rest, web @@ -43,7 +45,7 @@ packages = [options] use_scm_version = True -python_requires = >3.5 +python_requires = >=3.6 packages = find: include_package_data = True zip_safe = False From 2cd7c85d0f77ddf9656e12029ff7228fd2f0dff2 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Mon, 3 May 2021 10:24:10 +0100 Subject: [PATCH 025/103] chore: replace cspell with codespell (#1020) Change-Id: I35510172361b6c2d1309bf27d3cfddd834389074 --- .pre-commit-config.yaml | 12 + cspell.json | 208 ------------ jira/resilientsession.py | 2 +- package-lock.json | 661 --------------------------------------- package.json | 18 -- tests/tests.py | 2 +- tox.ini | 3 - 7 files changed, 14 insertions(+), 892 deletions(-) delete mode 100644 cspell.json delete mode 100644 package-lock.json delete mode 100644 package.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bfcb0db2..d40fda40d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,18 @@ repos: - id: debug-statements - id: check-yaml files: .*\.(yaml|yml)$ + - repo: https://github.com/codespell-project/codespell.git + rev: v2.0.0 + hooks: + - id: codespell + name: codespell + description: Checks for common misspellings in text files. + entry: codespell + language: python + types: [text] + args: [] + require_serial: false + additional_dependencies: [] - repo: https://gitlab.com/pycqa/flake8 rev: 3.9.1 hooks: diff --git a/cspell.json b/cspell.json deleted file mode 100644 index 18ff1b2a8..000000000 --- a/cspell.json +++ /dev/null @@ -1,208 +0,0 @@ -{ - "version": "0.1", - "language": "en", - "words": [ - "addfinalizer", - "appid", - "atexit", - "atlassian", - "atlassians", - "ausername", - "bdist", - "bspeakmon", - "capsys", - "categorised", - "Codecov", - "conda", - "cygwin", - "dae", - "Dalko", - "delete", - "deps", - "desk", - "devhelp", - "dgec", - "docutils", - "envars", - "envlist", - "envdir", - "envs", - "envvars", - "epub", - "errno", - "etree", - "favicon", - "favourite", - "favourites", - "fjira", - "fname", - "functools", - "fv", - "gerrit", - "googlicious", - "hashify", - "howto", - "hqi", - "I18NSPHINXOPTS", - "id", - "iDalko", - "ifeq", - "ifndef", - "ifneq", - "igrid", - "imghdr", - "iname", - "incompleted", - "inexistent", - "instafail", - "ipython", - "issueid", - "issuperset", - "itil", - "jira", - "jirapython", - "jirapythondoc", - "jirashell", - "jspa", - "k", - "ky", - "kzh", - "libkrb", - "lqqy", - "luk", - "makotemplate", - "mkdir", - "mktemp", - "myfilter", - "myid", - "navicat", - "nclqfp", - "netrc", - "nocheck", - "noqa", - "norecursedirs", - "oauth", - "oauthlib", - "onresolve", - "ornu", - "passenv", - "perc", - "posargs", - "printf", - "procs", - "proja", - "projb", - "project", - "pyargs", - "pycodestyle", - "pycontribs", - "pycrypto", - "pyenv", - "pyinstaller", - "pylint", - "pytest", - "pyyaml", - "qhcp", - "qthelp", - "reindex", - "reindexing", - "repo", - "repos", - "rnd", - "rndpassword", - "rrequirements", - "rsyncdirs", - "rsyncignore", - "rtype", - "sbarnea", - "schemeid", - "sdist", - "seqs", - "serialise", - "serialised", - "service", - "setenv", - "skipif", - "sorin", - "ssbarnea", - "str", - "strftime", - "symlinks", - "test", - "testenv", - "testsd", - "testvercomp", - "tfsds", - "th", - "toctree", - "tolower", - "TOXENV", - "toxinidir", - "toxworkdir", - "transitionid", - "truthy", - "trw", - "twz", - "txcwsb", - "tzinfo", - "ucfirst", - "ul", - "uname", - "undoc", - "unmark", - "unstaged", - "untranslate", - "venv", - "virtualenv", - "virtualenvs", - "websudo", - "woopsydoodle", - "workon", - "xargs", - "xdist", - "xenial", - "xfail", - "xscs", - "xsrf", - "yanc", - "ztravisdeb", - "LGPL" - ], - "flagWords": [], - "allowCompoundWords": true, - "dictionaries": [ - "python", - "html", - "css" - ], - "ignoreRegExpList": [ - "/'s\\b/", - "/\\br'/", - "/\\bu'/", - "/\\b-rrequirements/", - "[^\\s]{20,}", - "/I18NSPHINXOPTS/" - ], - "ignorePaths": [ - "__pycache__", - ".eggs", - ".tox", - "*.egg-info", - "*.egg", - "*.pyc", - "dist/**", - "docs/_build/**", - "node_modules/**", - "package-lock.json" - ], - "ignoreWords": [ - "AACCOUNTID", - "GDPR", - "I18NSPHINXOPTS", - "hdost", - "βρέθηκε", - "PYTHONHTTPSVERIFY", - "ptype", - "mypy" - ] -} diff --git a/jira/resilientsession.py b/jira/resilientsession.py index 3ee851bcc..a5fe81eb9 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -57,7 +57,7 @@ def raise_on_error(r, verb="???", **kwargs): # for debugging weird errors on CI if r.status_code not in [200, 201, 202, 204]: raise JIRAError(r.status_code, request=request, response=r, **kwargs) - # testing for the WTH bug exposed on + # testing for the bug exposed on # https://answers.atlassian.com/questions/11457054/answers/11975162 if ( r.status_code == 200 diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 6e67bf45e..000000000 --- a/package-lock.json +++ /dev/null @@ -1,661 +0,0 @@ -{ - "name": "python-jira", - "version": "0.0.1", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@cspell/dict-aws": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-1.0.14.tgz", - "integrity": "sha512-K21CfB4ZpKYwwDQiPfic2zJA/uxkbsd4IQGejEvDAhE3z8wBs6g6BwwqdVO767M9NgZqc021yAVpr79N5pWe3w==" - }, - "@cspell/dict-bash": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-1.0.12.tgz", - "integrity": "sha512-BOMHVW/m281mqUSJkZ3oiJiUUItLd7QdzpMjm428V9yBYFwIdbds1CeatS7C6kgpI2eBE4RXmy1Hjk/lR63Jew==" - }, - "@cspell/dict-companies": { - "version": "1.0.36", - "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-1.0.36.tgz", - "integrity": "sha512-Bk9mMJs9spzrtLxZsxBZIK6ukD9REfQYpuTBNJk/IiTViHVQ6ertHAgw1vRVtJAMxViv8dMLNtDyTpEXeaYm7w==" - }, - "@cspell/dict-cpp": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-1.1.38.tgz", - "integrity": "sha512-QqVMxVNYX9XtxzflpJ/888GSyjPU5VeotltsHql1BeEPxhyV27ud9bRKDrBGzCijCK/+MvCxiMZGDpYZqHTjXw==" - }, - "@cspell/dict-cryptocurrencies": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-1.0.10.tgz", - "integrity": "sha512-47ABvDJOkaST/rXipNMfNvneHUzASvmL6K/CbOFpYKfsd0x23Jc9k1yaOC7JAm82XSC/8a7+3Yu+Fk2jVJNnsA==" - }, - "@cspell/dict-csharp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-1.0.11.tgz", - "integrity": "sha512-nub+ZCiTgmT87O+swI+FIAzNwaZPWUGckJU4GN402wBq420V+F4ZFqNV7dVALJrGaWH7LvADRtJxi6cZVHJKeA==" - }, - "@cspell/dict-css": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-1.0.11.tgz", - "integrity": "sha512-2Or5oF5ojaXYD8QbO4Z+QdaNXSp+ZyNLJdeyKfejbxLvpL5feSNB0oYtTNrweFPTAvJKQ4DJsdEXy0/s31haRg==" - }, - "@cspell/dict-django": { - "version": "1.0.26", - "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-1.0.26.tgz", - "integrity": "sha512-mn9bd7Et1L2zuibc08GVHTiD2Go3/hdjyX5KLukXDklBkq06r+tb0OtKtf1zKodtFDTIaYekGADhNhA6AnKLkg==" - }, - "@cspell/dict-dotnet": { - "version": "1.0.25", - "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-1.0.25.tgz", - "integrity": "sha512-3BFhdquYqqjeI8Jm1dYepZKGEg+fKFhw7UfPkVdx13C4ETo5VlsS4FAblC0pCY21pDU3QgRZOGL1Bj+KWCGp/w==" - }, - "@cspell/dict-elixir": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-1.0.24.tgz", - "integrity": "sha512-pEX6GYlEx4Teusw/m+XmqoXzcHOqpcn1ZX4H33ONqR81XdPwbaKorBr1IG23Ic76IhwrFlOqs48tcnxrHYpFnA==" - }, - "@cspell/dict-en-gb": { - "version": "1.1.28", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb/-/dict-en-gb-1.1.28.tgz", - "integrity": "sha512-noOH+iv4xFpPxu1agiQgp5LhY/KA0Ir28y1xnC2QTtLvlIid7vIvgixBOz4Zi0P7lo/mPmMjQY+x7//2EKFDgQ==" - }, - "@cspell/dict-en_us": { - "version": "1.2.40", - "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-1.2.40.tgz", - "integrity": "sha512-e8leCvGAWPWQIw0SoozgEAiMt2YM12rafOuW4aQwgTJD++vp32a9RrnVL8olBfWaA57rRWWndbMSmPTrsO9mpg==" - }, - "@cspell/dict-filetypes": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-1.1.5.tgz", - "integrity": "sha512-yfkB37J+hL6W8qa4AknFp7u6CGECrw2ql2/y0lUKruLQYid0ApK+bH+ll+Sqgl2YS5QAOhclskc72aQHAcRJIQ==" - }, - "@cspell/dict-fonts": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-1.0.14.tgz", - "integrity": "sha512-VhIX+FVYAnqQrOuoFEtya6+H72J82cIicz9QddgknsTqZQ3dvgp6lmVnsQXPM3EnzA8n1peTGpLDwHzT7ociLA==" - }, - "@cspell/dict-fullstack": { - "version": "1.0.37", - "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-1.0.37.tgz", - "integrity": "sha512-ljVzUdIlBENMiyHUV06007hz2FPRt+BQmC9Jgn6iGIEQeAQp37Q6oIDmxv2lD65ScEIbysxXuaUgJ5x0j4a48A==" - }, - "@cspell/dict-golang": { - "version": "1.1.24", - "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-1.1.24.tgz", - "integrity": "sha512-qq3Cjnx2U1jpeWAGJL1GL0ylEhUMqyaR36Xij6Y6Aq4bViCRp+HRRqk0x5/IHHbOrti45h3yy7ii1itRFo+Xkg==" - }, - "@cspell/dict-haskell": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-1.0.13.tgz", - "integrity": "sha512-kvl8T84cnYRPpND/P3D86P6WRSqebsbk0FnMfy27zo15L5MLAb3d3MOiT1kW3vEWfQgzUD7uddX/vUiuroQ8TA==" - }, - "@cspell/dict-html": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-1.1.6.tgz", - "integrity": "sha512-RsZXIrmsnLcUpXfyZdNg7OtO2+e4p7m/qILg03kM6vhSUMY6ryCQNPWKrHqsl8+LBKd54EgFM+O5zcgq6IIsCw==" - }, - "@cspell/dict-html-symbol-entities": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-1.0.23.tgz", - "integrity": "sha512-PV0UBgcBFbBLf/m1wfkVMM8w96kvfHoiCGLWO6BR3Q9v70IXoE4ae0+T+f0CkxcEkacMqEQk/I7vuE9MzrjaNw==" - }, - "@cspell/dict-java": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-1.0.22.tgz", - "integrity": "sha512-CVAJ29dx1XwwutgsMgaj5eCl1Nc7X7qFhWL2KkAdu78A/NUIaS+1I9KS0hHhdZx/wLke9dH8TR7NyPQGpGxeAw==" - }, - "@cspell/dict-latex": { - "version": "1.0.25", - "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-1.0.25.tgz", - "integrity": "sha512-cEgg91Migqcp1SdVV7dUeMxbPDhxdNo6Fgq2eygAXQjIOFK520FFvh/qxyBvW90qdZbIRoU2AJpchyHfGuwZFA==" - }, - "@cspell/dict-lorem-ipsum": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-1.0.22.tgz", - "integrity": "sha512-yqzspR+2ADeAGUxLTfZ4pXvPl7FmkENMRcGDECmddkOiuEwBCWMZdMP5fng9B0Q6j91hQ8w9CLvJKBz10TqNYg==" - }, - "@cspell/dict-lua": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-1.0.16.tgz", - "integrity": "sha512-YiHDt8kmHJ8nSBy0tHzaxiuitYp+oJ66ffCYuFWTNB3//Y0SI4OGHU3omLsQVeXIfCeVrO4DrVvRDoCls9B5zQ==" - }, - "@cspell/dict-node": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-1.0.11.tgz", - "integrity": "sha512-q66zAqtNmuvZGKt4stRwQPFLsbOjZGGZOZ1HEbqpOkicxvF0BWhR0Di/JBq27PDxeqQP3S5sLeogQTSNQBuTww==" - }, - "@cspell/dict-npm": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-1.0.11.tgz", - "integrity": "sha512-mokmv9/Yk1yliDz97drWyuDWv7eKGEcFhdM43YSPK7GuMLh6i2ULOmORPFhUcjxQjPf0uySMDA2JguiQ4m5Lmg==" - }, - "@cspell/dict-php": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-1.0.23.tgz", - "integrity": "sha512-rRLf/09rXDrzs0DJuNXNmFVTw2b2zLmZKNF4LIPrFHYHvdfsMvwVqxkr/SAyhF8C6zi5sW0XYC/J0S/3IE927w==" - }, - "@cspell/dict-powershell": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-1.0.14.tgz", - "integrity": "sha512-hisOXXi5PBXB5YKtrJQIis2FIRHgSW1U0/sd4yI36lzb3ZMEvGJwdAdyhXN3IGiqRUNxMzJiXAeXfhnia4xPtQ==" - }, - "@cspell/dict-python": { - "version": "1.0.33", - "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-1.0.33.tgz", - "integrity": "sha512-tRmE4TzHDFPs7sJ1a3XbfyFrvRHwefVz+z1wkm6tkXK9TPrCbIS+rV/T8xhj205q4lpZQ/TkNB3lT40eLB9O8A==" - }, - "@cspell/dict-ruby": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-1.0.13.tgz", - "integrity": "sha512-YeN1acY38dgMYlEJ6iWPH+8qXB6seLKHm9BszXxaKT/IzGA9Y9XUWPGobeJFD5E/tC6HjvcqRKxEs8vnvakoLQ==" - }, - "@cspell/dict-rust": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-1.0.22.tgz", - "integrity": "sha512-7WOIzv0BPiU+MssZbbMk8K+HR/g9Bcvd0+jXJC3/AKT8L6l0Mx0Tr/oF7cJ4xvCYgA84nBz3PhMZkabGSz/Nkg==" - }, - "@cspell/dict-scala": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-1.0.21.tgz", - "integrity": "sha512-5V/R7PRbbminTpPS3ywgdAalI9BHzcEjEj9ug4kWYvBIGwSnS7T6QCFCiu+e9LvEGUqQC+NHgLY4zs1NaBj2vA==" - }, - "@cspell/dict-software-terms": { - "version": "1.0.27", - "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-1.0.27.tgz", - "integrity": "sha512-O6wCGuFSnr9G9Sr62zc7/XyruRRPI0/PJ0xZj8/R+hr+vFjDaScQnkqj10gTVoLAshk1TjL5Firnzyz3ibfgdQ==" - }, - "@cspell/dict-typescript": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-1.0.17.tgz", - "integrity": "sha512-CXCuXcrgAc56P3kL9I6gW6bZwTs6t3duyAtHerHg5YAYbPs6/4nXgniQgLgu8kjFHFy07XrqaaBdLU9V2DmMtQ==" - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==" - }, - "at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "requires": { - "fill-range": "^7.0.1" - } - }, - "chalk": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", - "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" - }, - "comment-json": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.1.0.tgz", - "integrity": "sha512-WEghmVYaNq9NlWbrkzQTSsya9ycLyxJxpTQfZEan6a5Jomnjw18zS3Podf8q1Zf9BvonvQd/+Z7Z39L7KKzzdQ==", - "requires": { - "array-timsort": "^1.0.3", - "core-util-is": "^1.0.2", - "esprima": "^4.0.1", - "has-own-prop": "^2.0.0", - "repeat-string": "^1.6.1" - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" - }, - "configstore": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", - "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", - "requires": { - "dot-prop": "^5.2.0", - "graceful-fs": "^4.1.2", - "make-dir": "^3.0.0", - "unique-string": "^2.0.0", - "write-file-atomic": "^3.0.0", - "xdg-basedir": "^4.0.0" - } - }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" - }, - "crypto-random-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" - }, - "cspell": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/cspell/-/cspell-4.2.8.tgz", - "integrity": "sha512-eqan8+lCU9bSp8Tl4+SR/ccBnuPyMmp7evck/RlMdFTjLh/s+3vQ5hQyBzbzK8w2MMqL84CymW7BwIOKjpylSg==", - "requires": { - "chalk": "^4.1.0", - "commander": "^7.0.0", - "comment-json": "^4.0.6", - "cspell-glob": "^0.1.25", - "cspell-lib": "^4.3.12", - "fs-extra": "^9.1.0", - "gensequence": "^3.1.1", - "get-stdin": "^8.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - } - }, - "cspell-glob": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-0.1.25.tgz", - "integrity": "sha512-/XaSHrGBpMJa+duFz3GKOWfrijrfdHT7a/XGgIcq3cymCSpOH+DPho42sl0jLI/hjM+8yv2m8aEoxRT8yVSnlg==", - "requires": { - "micromatch": "^4.0.2" - } - }, - "cspell-io": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-4.1.7.tgz", - "integrity": "sha512-V0/tUu9FnIS3v+vAvDT6NNa14Nc/zUNX8+YUUOfFAiDJJTdqefmvcWjOJBIMYBf3wIk9iWLmLbMM+bNHqr7DSQ==", - "requires": { - "iconv-lite": "^0.6.2", - "iterable-to-stream": "^1.0.1" - } - }, - "cspell-lib": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-4.3.12.tgz", - "integrity": "sha512-yCCb6MoW1K8Tsr/WVEQoO4dfYhH9bCsjQayccb8MlyDaNNuWJHuX+gUGHsZSXSuChSh8PrTWKXJzs13/uM977g==", - "requires": { - "@cspell/dict-aws": "^1.0.13", - "@cspell/dict-bash": "^1.0.11", - "@cspell/dict-companies": "^1.0.35", - "@cspell/dict-cpp": "^1.1.37", - "@cspell/dict-cryptocurrencies": "^1.0.10", - "@cspell/dict-csharp": "^1.0.10", - "@cspell/dict-css": "^1.0.10", - "@cspell/dict-django": "^1.0.25", - "@cspell/dict-dotnet": "^1.0.24", - "@cspell/dict-elixir": "^1.0.23", - "@cspell/dict-en-gb": "^1.1.27", - "@cspell/dict-en_us": "^1.2.39", - "@cspell/dict-filetypes": "^1.1.5", - "@cspell/dict-fonts": "^1.0.13", - "@cspell/dict-fullstack": "^1.0.36", - "@cspell/dict-golang": "^1.1.24", - "@cspell/dict-haskell": "^1.0.12", - "@cspell/dict-html": "^1.1.5", - "@cspell/dict-html-symbol-entities": "^1.0.23", - "@cspell/dict-java": "^1.0.22", - "@cspell/dict-latex": "^1.0.23", - "@cspell/dict-lorem-ipsum": "^1.0.22", - "@cspell/dict-lua": "^1.0.16", - "@cspell/dict-node": "^1.0.10", - "@cspell/dict-npm": "^1.0.10", - "@cspell/dict-php": "^1.0.23", - "@cspell/dict-powershell": "^1.0.14", - "@cspell/dict-python": "^1.0.32", - "@cspell/dict-ruby": "^1.0.12", - "@cspell/dict-rust": "^1.0.22", - "@cspell/dict-scala": "^1.0.21", - "@cspell/dict-software-terms": "^1.0.24", - "@cspell/dict-typescript": "^1.0.16", - "comment-json": "^4.1.0", - "configstore": "^5.0.1", - "cspell-io": "^4.1.7", - "cspell-trie-lib": "^4.2.8", - "cspell-util-bundle": "^4.1.11", - "fs-extra": "^9.1.0", - "gensequence": "^3.1.1", - "minimatch": "^3.0.4", - "resolve-from": "^5.0.0", - "resolve-global": "^1.0.0", - "vscode-uri": "^3.0.2" - } - }, - "cspell-trie-lib": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-4.2.8.tgz", - "integrity": "sha512-Nt3c0gxOYXIc3/yhALDukpje1BgR6guvlUKWQO2zb0r7qRWpwUw2j2YM4dWbHQeH/3Hx5ei4Braa6cMaiJ5YBw==", - "requires": { - "gensequence": "^3.1.1" - } - }, - "cspell-util-bundle": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/cspell-util-bundle/-/cspell-util-bundle-4.1.11.tgz", - "integrity": "sha512-or3OGKydZs1NwweMIgnA48k8H3F5zK4e5lonjUhpEzLYQZ2nB23decdoqZ8ogFC8pFTA40tZKDsMJ0b+65gX4Q==" - }, - "dot-prop": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", - "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", - "requires": { - "is-obj": "^2.0.0" - } - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" - }, - "gensequence": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-3.1.1.tgz", - "integrity": "sha512-ys3h0hiteRwmY6BsvSttPmkhC0vEQHPJduANBRtH/dlDPZ0UBIb/dXy80IcckXyuQ6LKg+PloRqvGER9IS7F7g==" - }, - "get-stdin": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", - "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==" - }, - "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "global-dirs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", - "requires": { - "ini": "^1.3.4" - } - }, - "graceful-fs": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", - "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "has-own-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==" - }, - "iconv-lite": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", - "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, - "iterable-to-stream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/iterable-to-stream/-/iterable-to-stream-1.0.1.tgz", - "integrity": "sha512-O62gD5ADMUGtJoOoM9U6LQ7i4byPXUNoHJ6mqsmkQJcom331ZJGDApWgDESWyBMEHEJRjtHozgIiTzYo9RU4UA==" - }, - "jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^2.0.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "requires": { - "semver": "^6.0.0" - } - }, - "micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", - "requires": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" - } - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "requires": { - "wrappy": "1" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" - }, - "picomatch": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz", - "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==" - }, - "repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=" - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" - }, - "resolve-global": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", - "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", - "requires": { - "global-dirs": "^0.1.1" - } - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" - }, - "signal-exit": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "requires": { - "is-number": "^7.0.0" - } - }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "requires": { - "is-typedarray": "^1.0.0" - } - }, - "unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "requires": { - "crypto-random-string": "^2.0.0" - } - }, - "universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" - }, - "vscode-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.2.tgz", - "integrity": "sha512-jkjy6pjU1fxUvI51P+gCsxg1u2n8LSt0W6KrCNQceaziKzff74GoWmjVG46KieVzybO1sttPQmYfrwSHey7GUA==" - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" - }, - "write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "requires": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "xdg-basedir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", - "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 2c3f8660c..000000000 --- a/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "python-jira", - "version": "0.0.1", - "license": "SEE LICENSE IN LICENSE", - "scripts": { - "spell": "npm -s run spell-files && npm -s run spell-commit", - "spell-commit": "git log -1 --pretty=%B > .git/commit.msg && cspell .git/commit.msg", - "spell-files": "git ls-files | xargs cspell --unique" - }, - "repository": { - "type": "git", - "url": "https://github.com/pycontribs/jira.git" - }, - "dependencies": { - "cspell": "^4.2.8", - "npm": "^6.10.0" - } -} diff --git a/tests/tests.py b/tests/tests.py index d3ddcebbe..7fbec9293 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -223,7 +223,7 @@ def __init__(self): # [7-8] python version A=0, B=1,.. # [9] A,B -- we may need more than one project - """ `jid` is important for avoiding concurency problems when + """ `jid` is important for avoiding concurrency problems when executing tests in parallel as we have only one test instance. jid length must be less than 9 characters because we may append diff --git a/tox.ini b/tox.ini index 9b4945947..68132601d 100644 --- a/tox.ini +++ b/tox.ini @@ -80,7 +80,6 @@ envars = PIP_USER=no whitelist_externals = git - npm [testenv:pkg] deps = @@ -101,8 +100,6 @@ commands = [testenv:lint] deps = pre-commit>=1.17.0 commands= - npm install - npm run spell python -m pre_commit run --color=always {posargs:--all} extras = skip_install = true From 37055fda603b979527f4a50c3d883a6d1ed663c9 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Mon, 3 May 2021 10:48:30 +0100 Subject: [PATCH 026/103] Switch to locked test dependencies (#1019) * removes test extra and moves it test-requirements.in * adds `deps` command for tox which should be used for *manually* bumping test dependencies when we see fit Change-Id: I981c1c90f96051fd400e77efd0f725f27f03d286 --- .fossa.yml | 16 +++++ MANIFEST.in | 1 + requirements.txt | 38 ++++++++++ setup.cfg | 22 ------ test-requirements.in | 21 ++++++ test-requirements.txt | 159 ++++++++++++++++++++++++++++++++++++++++++ tox.ini | 55 +++++++++------ 7 files changed, 269 insertions(+), 43 deletions(-) create mode 100644 .fossa.yml create mode 100644 requirements.txt create mode 100644 test-requirements.in create mode 100644 test-requirements.txt diff --git a/.fossa.yml b/.fossa.yml new file mode 100644 index 000000000..7a283dd20 --- /dev/null +++ b/.fossa.yml @@ -0,0 +1,16 @@ +# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) +# Visit https://fossa.com to learn more + +version: 2 +cli: + server: https://app.fossa.com + fetcher: custom + project: jira +analyze: + modules: + - name: . + type: pip + target: . + path: . + options: + strategy: requirements diff --git a/MANIFEST.in b/MANIFEST.in index 3c0f8bf8a..5dca7ac7d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,5 +6,6 @@ prune .github # Exclude these files exclude package-lock.json +exclude test-requirements.* recursive-exclude * *.py[co] recursive-exclude * __pycache__ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..42f9dcfa6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,38 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --extra=cli,docs,opt,async --output-file=requirements.txt setup.cfg +# +certifi==2020.12.5 + # via requests +chardet==4.0.0 + # via requests +defusedxml==0.7.1 + # via jira (setup.cfg) +idna==2.10 + # via requests +importlib-metadata==4.0.1 + # via keyring +keyring==23.0.1 + # via jira (setup.cfg) +oauthlib==3.1.0 + # via requests-oauthlib +requests-oauthlib==1.3.0 + # via jira (setup.cfg) +requests-toolbelt==0.9.1 + # via jira (setup.cfg) +requests==2.25.1 + # via + # jira (setup.cfg) + # requests-oauthlib + # requests-toolbelt +typing-extensions==3.10.0.0 + # via importlib-metadata +urllib3==1.26.4 + # via requests +zipp==3.4.1 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/setup.cfg b/setup.cfg index 86169a151..21e32c5c3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -74,28 +74,6 @@ opt = requests_kerberos async = requests-futures>=0.9.7 -test = - docutils>=0.12 - flaky - MarkupSafe>=0.23 - mypy - oauthlib - pre-commit # MIT - py >= 1.4 - pytest-cache - pytest-cov - pytest-instafail - pytest-sugar - pytest-timeout>=1.3.1 - pytest-xdist>=2.2 - pytest>=6.0.0,<7.0 # MIT - PyYAML>=5.1 # MIT - requests_mock # Apache-2 - requires.io # UNKNOWN!!! - tenacity # Apache-2 - wheel>=0.24.0 # MIT - xmlrunner>=1.7.7 # LGPL - yanc>=0.3.3 # GPL [options.entry_points] console_scripts = diff --git a/test-requirements.in b/test-requirements.in new file mode 100644 index 000000000..49e82616e --- /dev/null +++ b/test-requirements.in @@ -0,0 +1,21 @@ +docutils>=0.12 +flaky +MarkupSafe>=0.23 +mypy +oauthlib +pre-commit # MIT +py >= 1.4 +pytest-cache +pytest-cov +pytest-instafail +pytest-sugar +pytest-timeout>=1.3.1 +pytest-xdist>=2.2 +pytest>=6.0.0,<7.0 # MIT +PyYAML>=5.1 # MIT +requests_mock # Apache-2 +requires.io # UNKNOWN!!! +tenacity # Apache-2 +wheel>=0.24.0 # MIT +xmlrunner>=1.7.7 # LGPL +yanc>=0.3.3 # GPL diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..481320df3 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,159 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --output-file=test-requirements.txt setup.cfg test-requirements.in +# +apipkg==1.5 + # via execnet +appdirs==1.4.4 + # via virtualenv +attrs==20.3.0 + # via pytest +certifi==2020.12.5 + # via requests +cfgv==3.2.0 + # via pre-commit +chardet==4.0.0 + # via requests +coverage==5.5 + # via pytest-cov +defusedxml==0.7.1 + # via jira (setup.cfg) +distlib==0.3.1 + # via virtualenv +docutils==0.17.1 + # via -r test-requirements.in +execnet==1.8.0 + # via + # pytest-cache + # pytest-xdist +filelock==3.0.12 + # via virtualenv +flaky==3.7.0 + # via -r test-requirements.in +identify==2.2.4 + # via pre-commit +idna==2.10 + # via requests +importlib-metadata==4.0.1 + # via + # keyring + # pluggy + # pre-commit + # pytest + # virtualenv +importlib-resources==5.1.2 + # via + # pre-commit + # virtualenv +iniconfig==1.1.1 + # via pytest +keyring==23.0.1 + # via jira (setup.cfg) +markupsafe==1.1.1 + # via -r test-requirements.in +mypy-extensions==0.4.3 + # via mypy +mypy==0.812 + # via -r test-requirements.in +nodeenv==1.6.0 + # via pre-commit +oauthlib==3.1.0 + # via + # -r test-requirements.in + # requests-oauthlib +packaging==20.9 + # via + # pytest + # pytest-sugar +pluggy==0.13.1 + # via pytest +pre-commit==2.12.1 + # via -r test-requirements.in +py==1.10.0 + # via + # -r test-requirements.in + # pytest + # pytest-forked +pyparsing==2.4.7 + # via packaging +pytest-cache==1.0 + # via -r test-requirements.in +pytest-cov==2.11.1 + # via -r test-requirements.in +pytest-forked==1.3.0 + # via pytest-xdist +pytest-instafail==0.4.2 + # via -r test-requirements.in +pytest-sugar==0.9.4 + # via -r test-requirements.in +pytest-timeout==1.4.2 + # via -r test-requirements.in +pytest-xdist==2.2.1 + # via -r test-requirements.in +pytest==6.2.3 + # via + # -r test-requirements.in + # pytest-cache + # pytest-cov + # pytest-forked + # pytest-instafail + # pytest-sugar + # pytest-timeout + # pytest-xdist +pyyaml==5.4.1 + # via + # -r test-requirements.in + # pre-commit +requests-mock==1.9.2 + # via -r test-requirements.in +requests-oauthlib==1.3.0 + # via jira (setup.cfg) +requests-toolbelt==0.9.1 + # via jira (setup.cfg) +requests==2.25.1 + # via + # jira (setup.cfg) + # requests-mock + # requests-oauthlib + # requests-toolbelt + # requires.io +requires.io==0.2.6 + # via -r test-requirements.in +six==1.15.0 + # via + # requests-mock + # tenacity + # virtualenv +tenacity==7.0.0 + # via -r test-requirements.in +termcolor==1.1.0 + # via pytest-sugar +toml==0.10.2 + # via + # pre-commit + # pytest +typed-ast==1.4.3 + # via mypy +typing-extensions==3.10.0.0 + # via + # importlib-metadata + # mypy +urllib3==1.26.4 + # via requests +virtualenv==20.4.4 + # via pre-commit +wheel==0.36.2 + # via -r test-requirements.in +xmlrunner==1.7.7 + # via -r test-requirements.in +yanc==0.3.3 + # via -r test-requirements.in +zipp==3.4.1 + # via + # importlib-metadata + # importlib-resources + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/tox.ini b/tox.ini index 68132601d..f5975fc72 100644 --- a/tox.ini +++ b/tox.ini @@ -18,26 +18,6 @@ python = 3.8: py38 3.9: py39 -[testenv:docs] -extras = - docs -# changedir=docs -usedevelop = False -skipdist = False -setenv = - PYTHONHTTPSVERIFY=0 -commands = - sphinx-build \ - -a -n -v -W --keep-going \ - -b html --color \ - -d "{toxworkdir}/docs_doctree" \ - docs/ "{toxworkdir}/docs_out" - - # Print out the output docs dir and a way to serve html: - python -c \ - 'import pathlib; '\ - 'docs_dir = pathlib.Path(r"{toxworkdir}") / "docs_out"; index_file = docs_dir / "index.html"; print(f"\nDocumentation available under `file://\{index_file\}`\n\nTo serve docs, use `python3 -m http.server --directory \{docs_dir\} 0`\n")' - [testenv] usedevelop = True @@ -46,7 +26,9 @@ usedevelop = True extras = cli opt - test +deps = + -r requirements.txt + -r test-requirements.txt sitepackages=False commands= git clean -xdf jira tests @@ -81,6 +63,37 @@ envars = whitelist_externals = git +[testenv:deps] +description = Update dependency lock files +# Force it to use oldest supported version of python or we would lose ability +# to get pinning correctly. +basepython = python3.6 +deps = + pip-tools >= 6.1.0 +commands = + pip-compile -o requirements.txt setup.cfg --extra cli,docs,opt,async + pip-compile -o test-requirements.txt setup.cfg test-requirements.in + +[testenv:docs] +extras = + docs +# changedir=docs +usedevelop = False +skipdist = False +setenv = + PYTHONHTTPSVERIFY=0 +commands = + sphinx-build \ + -a -n -v -W --keep-going \ + -b html --color \ + -d "{toxworkdir}/docs_doctree" \ + docs/ "{toxworkdir}/docs_out" + + # Print out the output docs dir and a way to serve html: + python -c \ + 'import pathlib; '\ + 'docs_dir = pathlib.Path(r"{toxworkdir}") / "docs_out"; index_file = docs_dir / "index.html"; print(f"\nDocumentation available under `file://\{index_file\}`\n\nTo serve docs, use `python3 -m http.server --directory \{docs_dir\} 0`\n")' + [testenv:pkg] deps = collective.checkdocs>=0.2 From bb99305f5c7c0d00348bdb75b1cfd37b652cce9d Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Mon, 3 May 2021 10:48:57 +0100 Subject: [PATCH 027/103] Cleanup project badges (#1018) --- README.rst | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/README.rst b/README.rst index 5bcdb02a9..44e2741bd 100644 --- a/README.rst +++ b/README.rst @@ -8,9 +8,6 @@ Jira Python Library .. image:: https://img.shields.io/pypi/l/jira.svg :target: https://pypi.python.org/pypi/jira/ -.. image:: https://img.shields.io/pypi/wheel/jira.svg - :target: https://pypi.python.org/pypi/jira/ - .. image:: https://img.shields.io/github/issues/pycontribs/jira.svg :target: https://github.com/pycontribs/jira/issues @@ -22,13 +19,6 @@ Jira Python Library .. image:: https://readthedocs.org/projects/jira/badge/?version=master :target: https://jira.readthedocs.io/ -.. image:: https://travis-ci.com/pycontribs/jira.svg?branch=master - :target: https://travis-ci.com/pycontribs/jira - -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/python/black - :alt: Python Black Code Style - .. image:: https://codecov.io/gh/pycontribs/jira/branch/master/graph/badge.svg :target: https://codecov.io/gh/pycontribs/jira From ae6b746b06e1c2785777ec09069c4d1926810979 Mon Sep 17 00:00:00 2001 From: Chris S Date: Wed, 12 May 2021 12:31:58 +0100 Subject: [PATCH 028/103] Update client.py (#1005) Chance made to solve error: c.a.p.r.c.security.jersey.XsrfResourceFilter] Use of the 'nocheck' value for X-Atlassian-Token has been deprecated since rest 3.0.0. Please use a value of 'no-check' instead. --- jira/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jira/client.py b/jira/client.py index 8e0f30d63..ebd96d18a 100644 --- a/jira/client.py +++ b/jira/client.py @@ -887,7 +887,7 @@ def add_attachment(self, issue, attachment, filename=None): url, files={"file": (fname, attachment, "application/octet-stream")}, headers=CaseInsensitiveDict( - {"content-type": None, "X-Atlassian-Token": "nocheck"} + {"content-type": None, "X-Atlassian-Token": "no-check"} ), ) else: @@ -907,7 +907,7 @@ def file_stream(): url, data=m, headers=CaseInsensitiveDict( - {"content-type": m.content_type, "X-Atlassian-Token": "nocheck"} + {"content-type": m.content_type, "X-Atlassian-Token": "no-check"} ), retry_data=file_stream, ) From 75a1a6d375099925c5d36b48fd041dd75982bfac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 May 2021 13:43:40 +0100 Subject: [PATCH 029/103] Bump pytest from 6.2.3 to 6.2.4 (#1024) Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.2.3 to 6.2.4. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.2.3...6.2.4) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- test-requirements.txt | 39 +++------------------------------------ 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/test-requirements.txt b/test-requirements.txt index 481320df3..c11df8989 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -18,8 +18,6 @@ chardet==4.0.0 # via requests coverage==5.5 # via pytest-cov -defusedxml==0.7.1 - # via jira (setup.cfg) distlib==0.3.1 # via virtualenv docutils==0.17.1 @@ -36,21 +34,8 @@ identify==2.2.4 # via pre-commit idna==2.10 # via requests -importlib-metadata==4.0.1 - # via - # keyring - # pluggy - # pre-commit - # pytest - # virtualenv -importlib-resources==5.1.2 - # via - # pre-commit - # virtualenv iniconfig==1.1.1 # via pytest -keyring==23.0.1 - # via jira (setup.cfg) markupsafe==1.1.1 # via -r test-requirements.in mypy-extensions==0.4.3 @@ -60,9 +45,7 @@ mypy==0.812 nodeenv==1.6.0 # via pre-commit oauthlib==3.1.0 - # via - # -r test-requirements.in - # requests-oauthlib + # via -r test-requirements.in packaging==20.9 # via # pytest @@ -92,7 +75,7 @@ pytest-timeout==1.4.2 # via -r test-requirements.in pytest-xdist==2.2.1 # via -r test-requirements.in -pytest==6.2.3 +pytest==6.2.4 # via # -r test-requirements.in # pytest-cache @@ -108,16 +91,9 @@ pyyaml==5.4.1 # pre-commit requests-mock==1.9.2 # via -r test-requirements.in -requests-oauthlib==1.3.0 - # via jira (setup.cfg) -requests-toolbelt==0.9.1 - # via jira (setup.cfg) requests==2.25.1 # via - # jira (setup.cfg) # requests-mock - # requests-oauthlib - # requests-toolbelt # requires.io requires.io==0.2.6 # via -r test-requirements.in @@ -137,9 +113,7 @@ toml==0.10.2 typed-ast==1.4.3 # via mypy typing-extensions==3.10.0.0 - # via - # importlib-metadata - # mypy + # via mypy urllib3==1.26.4 # via requests virtualenv==20.4.4 @@ -150,10 +124,3 @@ xmlrunner==1.7.7 # via -r test-requirements.in yanc==0.3.3 # via -r test-requirements.in -zipp==3.4.1 - # via - # importlib-metadata - # importlib-resources - -# The following packages are considered to be unsafe in a requirements file: -# setuptools From c62de7cc61dd2ad762ceb7569d50ba362bc0fbff Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Wed, 12 May 2021 13:45:56 +0100 Subject: [PATCH 030/103] chore: isort imports (#1022) --- .pre-commit-config.yaml | 11 ++++ docs/conf.py | 3 +- examples/basic_auth.py | 1 + examples/basic_use.py | 3 +- examples/cookie_auth.py | 1 + examples/maintenance.py | 7 +-- jira/__init__.py | 3 +- jira/client.py | 115 ++++++++++++++++++--------------------- jira/jirashell.py | 6 +- jira/resilientsession.py | 7 +-- jira/resources.py | 7 +-- make_local_jira_user.py | 2 +- setup.cfg | 13 +++++ setup.py | 1 - tests/test_client.py | 10 ++-- tests/test_shell.py | 8 +-- tests/tests.py | 15 ++--- 17 files changed, 110 insertions(+), 103 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d40fda40d..4f4bb387e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,6 +35,17 @@ repos: rev: 3.9.1 hooks: - id: flake8 + - repo: https://github.com/pycqa/isort + rev: 5.8.0 + hooks: + - id: isort + name: isort (python) + - id: isort + name: isort (cython) + types: [cython] + - id: isort + name: isort (pyi) + types: [pyi] - repo: https://github.com/adrienverge/yamllint.git rev: v1.26.1 hooks: diff --git a/docs/conf.py b/docs/conf.py index 677fbee18..75d7ec6a8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,9 +12,10 @@ # serve to show the default. import os -import sphinx_rtd_theme import sys +import sphinx_rtd_theme + # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. diff --git a/examples/basic_auth.py b/examples/basic_auth.py index 9587261f6..859aba94d 100644 --- a/examples/basic_auth.py +++ b/examples/basic_auth.py @@ -2,6 +2,7 @@ # username and password over HTTP BASIC authentication. from collections import Counter + from jira import JIRA # By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK. diff --git a/examples/basic_use.py b/examples/basic_use.py index c2f4a4476..9ab3199f6 100644 --- a/examples/basic_use.py +++ b/examples/basic_use.py @@ -1,8 +1,9 @@ # This script shows how to use the client in anonymous mode # against jira.atlassian.com. -from jira import JIRA import re +from jira import JIRA + # By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK # (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details). # Override this with the options parameter. diff --git a/examples/cookie_auth.py b/examples/cookie_auth.py index cdc4b608f..d399fc508 100644 --- a/examples/cookie_auth.py +++ b/examples/cookie_auth.py @@ -2,6 +2,7 @@ # username and password over HTTP BASIC authentication. from collections import Counter + from jira import JIRA # By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK. diff --git a/examples/maintenance.py b/examples/maintenance.py index 02a2522af..cbeb66e0d 100755 --- a/examples/maintenance.py +++ b/examples/maintenance.py @@ -4,12 +4,11 @@ # This script will cleanup your jira instance by removing all projects and # it is used to clean the CI/CD Jira server used for testing. # -import os -from jira import Role, Issue, JIRA, JIRAError, Project # noqa -import logging - import json +import logging +import os +from jira import JIRA, Issue, JIRAError, Project, Role # noqa logging.getLogger().setLevel(logging.DEBUG) logging.getLogger("requests").setLevel(logging.INFO) diff --git a/jira/__init__.py b/jira/__init__.py index 90f696495..54af82f72 100644 --- a/jira/__init__.py +++ b/jira/__init__.py @@ -7,9 +7,9 @@ except Exception: __version__ = "unknown" +from jira.client import JIRA # noqa: E402 from jira.client import Comment # noqa: E402 from jira.client import Issue # noqa: E402 -from jira.client import JIRA # noqa: E402 from jira.client import Priority # noqa: E402 from jira.client import Project # noqa: E402 from jira.client import Role # noqa: E402 @@ -19,7 +19,6 @@ from jira.config import get_jira # noqa: E402 from jira.exceptions import JIRAError # noqa: E402 - __all__ = ( "Comment", "__version__", diff --git a/jira/client.py b/jira/client.py index ebd96d18a..632f24ae0 100644 --- a/jira/client.py +++ b/jira/client.py @@ -1,84 +1,76 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -from requests.auth import AuthBase - """ This module implements a friendly (well, friendlier) interface between the raw JSON responses from Jira and the Resource/dict abstractions provided by this library. Users will construct a JIRA object as described below. Full API documentation can be found at: https://jira.readthedocs.io/en/latest/ """ -from functools import lru_cache -from functools import wraps - -import imghdr -import mimetypes - -from collections.abc import Iterable +import calendar import copy +import datetime +import hashlib +import imghdr import json import logging +import mimetypes import os import re - - -import calendar -import datetime -import hashlib -from numbers import Number -import requests import sys import time import warnings +from collections import OrderedDict +from collections.abc import Iterable +from functools import lru_cache, wraps +from numbers import Number +from urllib.parse import urlparse +import requests +from pkg_resources import parse_version +from requests.auth import AuthBase from requests.utils import get_netrc_auth -from urllib.parse import urlparse + +from jira import __version__ # GreenHopper specific resources from jira.exceptions import JIRAError -from jira.resilientsession import raise_on_error -from jira.resilientsession import ResilientSession +from jira.resilientsession import ResilientSession, raise_on_error # Jira-specific resources -from jira.resources import Attachment -from jira.resources import Board -from jira.resources import Comment -from jira.resources import Component -from jira.resources import Customer -from jira.resources import CustomFieldOption -from jira.resources import Dashboard -from jira.resources import Filter -from jira.resources import GreenHopperResource -from jira.resources import Issue -from jira.resources import IssueLink -from jira.resources import IssueLinkType -from jira.resources import IssueType -from jira.resources import Priority -from jira.resources import Project -from jira.resources import RemoteLink -from jira.resources import RequestType -from jira.resources import Resolution -from jira.resources import Resource -from jira.resources import Role -from jira.resources import SecurityLevel -from jira.resources import ServiceDesk -from jira.resources import Sprint -from jira.resources import Status -from jira.resources import StatusCategory -from jira.resources import User -from jira.resources import Group -from jira.resources import Version -from jira.resources import Votes -from jira.resources import Watchers -from jira.resources import Worklog - -from jira import __version__ -from jira.utils import CaseInsensitiveDict -from jira.utils import json_loads -from jira.utils import threaded_requests -from pkg_resources import parse_version - -from collections import OrderedDict +from jira.resources import ( + Attachment, + Board, + Comment, + Component, + Customer, + CustomFieldOption, + Dashboard, + Filter, + GreenHopperResource, + Group, + Issue, + IssueLink, + IssueLinkType, + IssueType, + Priority, + Project, + RemoteLink, + RequestType, + Resolution, + Resource, + Role, + SecurityLevel, + ServiceDesk, + Sprint, + Status, + StatusCategory, + User, + Version, + Votes, + Watchers, + Worklog, +) +from jira.utils import CaseInsensitiveDict, json_loads, threaded_requests try: # noinspection PyUnresolvedReferences @@ -3048,9 +3040,7 @@ def _create_kerberos_session(self, timeout, kerberos_options=None): if kerberos_options is None: kerberos_options = {} - from requests_kerberos import DISABLED - from requests_kerberos import HTTPKerberosAuth - from requests_kerberos import OPTIONAL + from requests_kerberos import DISABLED, OPTIONAL, HTTPKerberosAuth if kerberos_options.get("mutual_authentication", "OPTIONAL") == "OPTIONAL": mutual_authentication = OPTIONAL @@ -3166,8 +3156,9 @@ def _find_for_resource(self, resource_cls, ids, expand=None): def _try_magic(self): try: - import magic import weakref + + import magic except ImportError: self._magic = None else: diff --git a/jira/jirashell.py b/jira/jirashell.py index aa1f0221d..605d97e0c 100644 --- a/jira/jirashell.py +++ b/jira/jirashell.py @@ -7,22 +7,20 @@ """ import argparse +import configparser import os import sys import webbrowser from getpass import getpass +from urllib.parse import parse_qsl import keyring import requests from oauthlib.oauth1 import SIGNATURE_RSA from requests_oauthlib import OAuth1 -from urllib.parse import parse_qsl from jira import JIRA, __version__ -import configparser - - CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".jira-python", "jirashell.ini") diff --git a/jira/resilientsession.py b/jira/resilientsession.py index a5fe81eb9..9286fd70a 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -1,13 +1,12 @@ # -*- coding: utf-8 -*- import json import logging - - import random -from requests.exceptions import ConnectionError -from requests import Session import time +from requests import Session +from requests.exceptions import ConnectionError + from jira.exceptions import JIRAError logging.getLogger("jira").addHandler(logging.NullHandler()) diff --git a/jira/resources.py b/jira/resources.py index 3725e4320..3a41d0cd4 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -4,15 +4,12 @@ into usable objects. """ +import json import logging import re import time -import json - -from jira.utils import CaseInsensitiveDict -from jira.utils import json_loads -from jira.utils import threaded_requests +from jira.utils import CaseInsensitiveDict, json_loads, threaded_requests __all__ = ( "Resource", diff --git a/make_local_jira_user.py b/make_local_jira_user.py index 084c2db96..984c7c77b 100644 --- a/make_local_jira_user.py +++ b/make_local_jira_user.py @@ -2,11 +2,11 @@ as the empty JIRA instance isn't provisioned with one. """ import time +from os import environ import requests from jira import JIRA -from os import environ CI_JIRA_URL = environ["CI_JIRA_URL"] diff --git a/setup.cfg b/setup.cfg index 21e32c5c3..f35f69c23 100644 --- a/setup.cfg +++ b/setup.cfg @@ -106,6 +106,19 @@ ignore = E741,W503,W504,H,E501,E203 # 88 is official black default: max-line-length = 88 +[isort] +# start - black compatible settings +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 88 +# end - black compatible settings +known_first_party = + jira + tests + [tool:pytest] norecursedirs = . jira _build tmp* lib/third lib *.egg bin distutils build docs demo python_files = *.py diff --git a/setup.py b/setup.py index 140dc8d4d..a5bf1b484 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,5 @@ # !/usr/bin/env python import setuptools - if __name__ == "__main__": setuptools.setup(use_scm_version=True) diff --git a/tests/test_client.py b/tests/test_client.py index 52408f129..6a8a36716 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- import getpass + import pytest +import jira.client +from jira import JIRA, Issue, JIRAError, Project, Role # noqa + # from tenacity import retry # from tenacity import wait_incrementing -from tests import get_unique_project_name -from tests import JiraTestManager - -from jira import Role, Issue, JIRA, JIRAError, Project # noqa -import jira.client +from tests import JiraTestManager, get_unique_project_name @pytest.fixture() diff --git a/tests/test_shell.py b/tests/test_shell.py index 719b0aa18..b902c2c7c 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- -import pytest # noqa import io -import requests # noqa import sys -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch +import pytest # noqa +import requests # noqa -from jira import Role, Issue, JIRA, JIRAError, Project # noqa import jira.jirashell as jirashell +from jira import JIRA, Issue, JIRAError, Project, Role # noqa @pytest.fixture diff --git a/tests/tests.py b/tests/tests.py index 7fbec9293..29e8d79fb 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -8,22 +8,19 @@ import re import string import sys +import unittest from time import sleep +from typing import Any, Dict +from unittest import mock -from flaky import flaky import py import pytest import requests -from typing import Any, Dict - -import unittest - -from unittest import mock - +from flaky import flaky import jira # noqa -from jira import Role, Issue, JIRA, JIRAError, Project # noqa -from jira.resources import Resource, cls_for_resource, Group, UnknownResource # noqa +from jira import JIRA, Issue, JIRAError, Project, Role # noqa +from jira.resources import Group, Resource, UnknownResource, cls_for_resource # noqa TEST_ROOT = os.path.dirname(__file__) TEST_ICON_PATH = os.path.join(TEST_ROOT, "icon.png") From 91d2d060754d8a5a1983bbcc8bc7bc1eaa68c046 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Wed, 12 May 2021 13:46:16 +0100 Subject: [PATCH 031/103] docs: remove cspell reference in README (#1021) --- README.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.rst b/README.rst index 44e2741bd..2d4763e59 100644 --- a/README.rst +++ b/README.rst @@ -97,10 +97,6 @@ Setup - ``docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone`` * Lint - ``tox -e lint`` - - Note: Windows users trying to run locally will need to: - - Comment out the ``npm`` commands in the ``lint`` environment before running the ``lint`` environment - - Run ``npm install`` manually - - Run ``cspell "**" --unique`` manually - this relies on the ``cspell.json`` to check the right files * Run tests - ``tox`` * Run tests for one env only From 7646f6b938d227a669d2298ae703593b23a4423d Mon Sep 17 00:00:00 2001 From: Stanislav Ulrych Date: Wed, 12 May 2021 15:18:02 +0200 Subject: [PATCH 032/103] Example of the email address and API token initialization. (#893) --- docs/examples.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/examples.rst b/docs/examples.rst index 5b4a1f80b..50b0a88d7 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -61,6 +61,11 @@ Pass a tuple of (username, password) to the ``basic_auth`` constructor argument: auth_jira = JIRA(basic_auth=('username', 'password')) +Or pass a tuple of (email, api_token) to the ``basic_auth`` constructor argument (JIRA cloud):: + + auth_jira = JIRA(basic_auth=('email', 'API token')) + + OAuth ^^^^^ From 694c5287cc4fd5bb0a2e83a36a32029e3528632c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 May 2021 14:19:02 +0100 Subject: [PATCH 033/103] Bump markupsafe from 1.1.1 to 2.0.0 (#1026) Bumps [markupsafe](https://github.com/pallets/markupsafe) from 1.1.1 to 2.0.0. - [Release notes](https://github.com/pallets/markupsafe/releases) - [Changelog](https://github.com/pallets/markupsafe/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/markupsafe/compare/1.1.1...2.0.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sorin Sbarnea --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index c11df8989..5f26a0ad2 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -36,7 +36,7 @@ idna==2.10 # via requests iniconfig==1.1.1 # via pytest -markupsafe==1.1.1 +markupsafe==2.0.0 # via -r test-requirements.in mypy-extensions==0.4.3 # via mypy From 8ad5eff98ea0731f8ab71893a409961c86422caa Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Wed, 12 May 2021 20:15:34 +0100 Subject: [PATCH 034/103] Add publish workflow (#1027) --- .github/workflows/publish.yml | 77 +++++++++++++++++++++++++++++++++++ Makefile | 29 +------------ README.rst | 2 +- tox.ini | 3 +- 4 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..e6ac68729 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,77 @@ +name: release + +on: + release: + types: [published, created, edited] + +jobs: + publish: + environment: publish + if: startsWith(github.ref, 'refs/tags/') # Only release during tags + runs-on: ubuntu-20.04 + + env: + PY_COLORS: 1 + TOXENV: publish + + steps: + - name: Switch to using Python 3.6 by default + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install tox + run: python3 -m pip install --user tox + - name: Check out src from Git + uses: actions/checkout@v2 + with: + # Get shallow Git history (default) for release events + # but have a complete clone for any other workflows. + # Both options fetch tags but since we're going to remove + # one from HEAD in non-create-tag workflows, we need full + # history for them. + fetch-depth: >- + ${{ + ( + ( + github.event_name == 'create' && + github.event.ref_type == 'tag' + ) || + github.event_name == 'release' + ) && + 1 || 0 + }} + - name: Drop Git tags from HEAD for non-tag-create and non-release events + if: >- + ( + github.event_name != 'create' || + github.event.ref_type != 'tag' + ) && + github.event_name != 'release' + run: >- + git tag --points-at HEAD + | + xargs git tag --delete + - name: Build dists + run: python3 -m tox + - name: Publish to test.pypi.org + if: >- + ( + github.event_name == 'push' && + github.ref == format( + 'refs/heads/{0}', github.event.repository.default_branch + ) + ) || + ( + github.event_name == 'create' && + github.event.ref_type == 'tag' + ) + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.TESTPYPI_PASSWORD }} + repository_url: https://test.pypi.org/legacy/ + - 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 + with: + password: ${{ secrets.PYPI_PASSWORD }} diff --git a/Makefile b/Makefile index b19faa59e..969aeb309 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -all: info clean lint test docs dist upload release -.PHONY: all docs upload info req dist +all: info clean lint test docs dist +.PHONY: all docs info req dist PACKAGE_NAME := $(shell python setup.py --name) PACKAGE_VERSION := $(shell python setup.py --version) @@ -106,28 +106,3 @@ tag: bumpversion --feature --no-input git push origin master git push --tags - -release: req -ifeq ($(GIT_BRANCH),master) - tag -else - upload - web - - @echo "INFO: Skipping release on this branch." -endif - -upload: - rm -f dist/* -ifeq ($(GIT_BRANCH),develop) - @echo "INFO: Upload package to testpypi.python.org" - $(PREFIX)python setup.py check --restructuredtext --strict - $(PREFIX)python setup.py sdist bdist_wheel - $(PREFIX)twine upload --repository-url https://test.pypi.org/legacy/ dist/* -endif -ifeq ($(GIT_BRANCH),master) - @echo "INFO: Upload package to pypi.python.org" - $(PREFIX)python setup.py check --restructuredtext --strict - $(PREFIX)python setup.py sdist bdist_wheel - $(PREFIX)twine upload dist/* -endif diff --git a/README.rst b/README.rst index 2d4763e59..4d99332e2 100644 --- a/README.rst +++ b/README.rst @@ -102,7 +102,7 @@ Setup * Run tests for one env only - ``tox -e py37`` * Build and publish with TWINE - - ``tox -e upload`` + - ``tox -e publish`` .. _Fork: https://help.github.com/articles/fork-a-repo/ .. _sync: https://help.github.com/articles/syncing-a-fork/ diff --git a/tox.ini b/tox.ini index f5975fc72..3b2ba5489 100644 --- a/tox.ini +++ b/tox.ini @@ -123,7 +123,8 @@ usedevelop = false commands= python examples/maintenance.py -[testenv:upload] +[testenv:publish] +description = Publish package envdir = {toxworkdir}/pkg deps = {[testenv:pkg]deps} commands = From 8705ff2c68cd3389b4e9d06b737f2f8ab98587cf Mon Sep 17 00:00:00 2001 From: pperum002c <37694513+pperum002c@users.noreply.github.com> Date: Thu, 13 May 2021 01:14:36 +0530 Subject: [PATCH 035/103] Comment Author Name and Time (#1001) * Comment Author Name and Time * Update docs/examples.rst Co-authored-by: studioj <22102283+studioj@users.noreply.github.com> Co-authored-by: Sorin Sbarnea Co-authored-by: studioj <22102283+studioj@users.noreply.github.com> --- docs/examples.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/examples.rst b/docs/examples.rst index 50b0a88d7..df70037ea 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -256,6 +256,11 @@ Get an individual comment if you know its ID:: comment = jira.comment('JRA-1330', '10234') +Get comment author name and comment creation timestamp if you know its ID:: + + author = jira.comment('JRA-1330', '10234').author.displayName + time = jira.comment('JRA-1330', '10234').created + Adding, editing and deleting comments is similarly straightforward:: comment = jira.add_comment('JRA-1330', 'new comment') # no Issue object required From cb7561a1252d41fdb65832898cdb4ff322b92c24 Mon Sep 17 00:00:00 2001 From: Adriaan Knapen Date: Wed, 12 May 2021 22:46:56 +0300 Subject: [PATCH 036/103] chore(github): adds jira instance type to bug-report template (#963) * chore(github): adds jira instance type to bug-report template Self-hosted and Atlassian's cloud hosted versions of Jira start to have diverging APIs. Hence it is relevant to know for a bug report on what type of JIRA instance this error occurred, such that later we can more effectively triage issues (or know which issues to ignore if we decide to drop support for either of them). * chore: updates bug_report.md Co-authored-by: adehad <26027314+adehad@users.noreply.github.com> Co-authored-by: adehad <26027314+adehad@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/bug_report.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 20105a190..a12c2cc72 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,6 +26,9 @@ A code block with the any trace messages. **Version Information** +Type of Jira instance: +- [ ] Jira Cloud (Hosted by Atlassian) +- [ ] Jira Server or Data Center (Self-hosted) Python Interpreter: jira-python: OS: From 9884d7601a38f114281bc5ded597b7418c66404c Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Wed, 12 May 2021 20:51:00 +0100 Subject: [PATCH 037/103] Cleanup tox.ini publish (#1028) --- tox.ini | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index 3b2ba5489..88078dadf 100644 --- a/tox.ini +++ b/tox.ini @@ -96,12 +96,11 @@ commands = [testenv:pkg] deps = - collective.checkdocs>=0.2 - build>=0.3.0 - pip>=19.2.3 - setuptools>=41.4 - twine>=2.0.0 - wheel>=0.33.6 + build>=0.3.1.post1 + pip>=21.1.1 + setuptools>=56.2.0 + twine>=3.4.1 + wheel>=0.36.2 commands = git clean -xdf dist python setup.py check -m -s @@ -109,6 +108,7 @@ commands = # python setup.py sdist bdist_wheel python -m build --wheel --sdist . python -m twine check dist/* +usedevelop = false [testenv:lint] deps = pre-commit>=1.17.0 @@ -130,3 +130,4 @@ deps = {[testenv:pkg]deps} commands = {[testenv:pkg]commands} twine upload dist/* +usedevelop = false From 1a4ff74c5b0bdc0a9172e0e10f2685d37525304e Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Wed, 12 May 2021 21:05:56 +0100 Subject: [PATCH 038/103] More publish fixes (#1029) --- .github/workflows/publish.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e6ac68729..6642c9b39 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,7 @@ name: release on: release: - types: [published, created, edited] + types: [created, edited] jobs: publish: @@ -15,12 +15,18 @@ jobs: TOXENV: publish steps: + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install gcc libkrb5-dev + python -m pip install --upgrade pip + python -m pip install --upgrade tox + - name: Switch to using Python 3.6 by default uses: actions/setup-python@v2 with: python-version: 3.6 - - name: Install tox - run: python3 -m pip install --user tox + - name: Check out src from Git uses: actions/checkout@v2 with: @@ -40,6 +46,7 @@ jobs: ) && 1 || 0 }} + - name: Drop Git tags from HEAD for non-tag-create and non-release events if: >- ( @@ -51,8 +58,10 @@ jobs: git tag --points-at HEAD | xargs git tag --delete + - name: Build dists run: python3 -m tox + - name: Publish to test.pypi.org if: >- ( @@ -69,6 +78,7 @@ jobs: with: password: ${{ secrets.TESTPYPI_PASSWORD }} repository_url: https://test.pypi.org/legacy/ + - name: Publish to pypi.org if: >- # "create" workflows run separately from "push" & "pull_request" github.event_name == 'release' From d3f678acbbb919c1eedd204e50e2a05866771e00 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Wed, 12 May 2021 21:10:51 +0100 Subject: [PATCH 039/103] Even more release fixes (#1030) --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6642c9b39..3ba5de8e7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,8 +19,8 @@ jobs: run: | sudo apt-get update sudo apt-get install gcc libkrb5-dev - python -m pip install --upgrade pip - python -m pip install --upgrade tox + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade tox - name: Switch to using Python 3.6 by default uses: actions/setup-python@v2 From 13421f125627ac30bc2a957df59cded955732c4f Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Wed, 12 May 2021 21:22:23 +0100 Subject: [PATCH 040/103] Even more release fixes (#1031) --- .github/workflows/publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3ba5de8e7..68a15cf28 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,6 +15,11 @@ jobs: TOXENV: publish steps: + - name: Switch to using Python 3.6 by default + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install Dependencies run: | sudo apt-get update @@ -22,11 +27,6 @@ jobs: python3 -m pip install --upgrade pip python3 -m pip install --upgrade tox - - name: Switch to using Python 3.6 by default - uses: actions/setup-python@v2 - with: - python-version: 3.6 - - name: Check out src from Git uses: actions/checkout@v2 with: From 4355170fbfbf97c37a9926d08b8b13aa2f9c0bde Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Wed, 12 May 2021 21:27:16 +0100 Subject: [PATCH 041/103] Run only tox pkg on publish (#1032) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 68a15cf28..96c057444 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: env: PY_COLORS: 1 - TOXENV: publish + TOXENV: pkg steps: - name: Switch to using Python 3.6 by default From 2d806f600bbf98901575cd5ef43a201861a2afd2 Mon Sep 17 00:00:00 2001 From: studioj <22102283+studioj@users.noreply.github.com> Date: Sat, 15 May 2021 14:08:26 +0200 Subject: [PATCH 042/103] cleaning up most travis references (#1040) Co-authored-by: Neefs, Jef --- README.rst | 7 ++- jira/exceptions.py | 16 +++---- setup.cfg | 2 +- tests/tests.py | 108 ++++++++++++++++----------------------------- 4 files changed, 48 insertions(+), 85 deletions(-) diff --git a/README.rst b/README.rst index 4d99332e2..b937ab534 100644 --- a/README.rst +++ b/README.rst @@ -85,7 +85,6 @@ Development takes place on GitHub_: * ``master`` - (default branch) contains the primary development stream. Tags will be used to show latest releases. -.. _GitHub: https://github.com/pycontribs/jira Setup ===== @@ -116,15 +115,15 @@ In addition to all the contributors we would like to thank to these companies: * Atlassian_ for developing such a powerful issue tracker and for providing a free on-demand Jira_ instance that we can use for continuous integration testing. * JetBrains_ for providing us with free licenses of PyCharm_ -* Travis_ for hosting our continuous integration +* GitHub_ for hosting our continuous integration and our git repo * Navicat_ for providing us free licenses of their powerful database client GUI tools. .. _Atlassian: https://www.atlassian.com/ .. _Jira: https://pycontribs.atlassian.net .. _JetBrains: https://www.jetbrains.com/ .. _PyCharm: https://www.jetbrains.com/pycharm/ -.. _Travis: https://travis-ci.org/ -.. _navicat: https://www.navicat.com/ +.. _GitHub: https://github.com/pycontribs/jira +.. _Navicat: https://www.navicat.com/ .. image:: https://raw.githubusercontent.com/pycontribs/resources/master/logos/x32/logo-atlassian.png :target: https://www.atlassian.com/ diff --git a/jira/exceptions.py b/jira/exceptions.py index c1be44868..532c90221 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -7,8 +7,8 @@ class JIRAError(Exception): """General error raised for all problems in operation of the client.""" log_to_tempfile = True - if "TRAVIS" in os.environ: - log_to_tempfile = False # Travis is keeping only the console log. + if "GITHUB_ACTION" in os.environ: + log_to_tempfile = False # GitHub Actions is keeping only the console log. def __init__( self, @@ -39,12 +39,8 @@ def __init__( self.request = request self.response = response self.headers = kwargs.get("headers", None) - self.log_to_tempfile = False - self.travis = False - if "PYJIRA_LOG_TO_TEMPFILE" in os.environ: - self.log_to_tempfile = True - if "TRAVIS" in os.environ: - self.travis = True + self.log_to_tempfile = "PYJIRA_LOG_TO_TEMPFILE" in os.environ + self.ci_run = "GITHUB_ACTION" in os.environ def __str__(self): """Return a string representation of the error. @@ -68,8 +64,8 @@ def __str__(self): if self.response is not None and hasattr(self.response, "text"): details += "\n\tresponse text = %s" % self.response.text - # separate logging for Travis makes sense. - if self.travis: + # separate logging for CI makes sense. + if self.ci_run: if self.text: t += "\n\ttext: %s" % self.text t += details diff --git a/setup.cfg b/setup.cfg index f35f69c23..512262dd1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,7 +14,7 @@ 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: Travis = https://travis-ci.com/pycontribs/jira + CI: GitHub Actions = https://github.com/pycontribs/jira/actions Source Code = https://github.com/pycontribs/jira.git Documentation = https://jira.readthedocs.io/en/master/ Forum = https://community.atlassian.com/t5/tag/jira-python/tg-p?sort=recent diff --git a/tests/tests.py b/tests/tests.py index 29e8d79fb..0ce2842d5 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -70,20 +70,15 @@ def hashify(some_string, max_len=8): def get_unique_project_name(): - jid = "" user = re.sub("[^A-Z_]", "", getpass.getuser().upper()) - if user == "TRAVIS" and "TRAVIS_JOB_NUMBER" in os.environ: + if "GITHUB_ACTION" in os.environ and "GITHUB_RUN_NUMBER" in os.environ: # please note that user underline (_) is not supported by # Jira even if it is documented as supported. - jid = "T" + hashify(user + os.environ["TRAVIS_JOB_NUMBER"]) - else: - identifier = ( - user - + chr(ord("A") + sys.version_info[0]) - + chr(ord("A") + sys.version_info[1]) - ) - jid = "Z" + hashify(identifier) - return jid + return "GH" + hashify(user + os.environ["GITHUB_RUN_NUMBER"]) + identifier = ( + user + chr(ord("A") + sys.version_info[0]) + chr(ord("A") + sys.version_info[1]) + ) + return "Z" + hashify(identifier) class JiraTestManager(object): @@ -102,14 +97,12 @@ def __init__(self): if not self.__dict__: self.initialized = 0 + self.max_retries = 5 if "CI_JIRA_URL" in os.environ: self.CI_JIRA_URL = os.environ["CI_JIRA_URL"] - self.max_retries = 5 else: self.CI_JIRA_URL = "https://pycontribs.atlassian.net" - self.max_retries = 5 - if "CI_JIRA_ADMIN" in os.environ: self.CI_JIRA_ADMIN = os.environ["CI_JIRA_ADMIN"] else: @@ -141,27 +134,6 @@ def __init__(self): "key_cert": KEY_CERT_DATA, } ) - else: - if self.CI_JIRA_ADMIN: - self.jira_admin = JIRA( - self.CI_JIRA_URL, - basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), - logging=False, - validate=True, - max_retries=self.max_retries, - ) - else: - self.jira_admin = JIRA( - self.CI_JIRA_URL, - validate=True, - logging=False, - max_retries=self.max_retries, - ) - if not self.jira_admin.current_user(): - self.initialized = 1 - sys.exit(3) - - if OAUTH: self.jira_sysadmin = JIRA( oauth={ "access_token": "4ul1ETSFo7ybbIxAxzyRal39cTrwEGFv", @@ -172,21 +144,6 @@ def __init__(self): logging=False, max_retries=self.max_retries, ) - else: - if self.CI_JIRA_ADMIN: - self.jira_sysadmin = JIRA( - self.CI_JIRA_URL, - basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), - logging=False, - validate=True, - max_retries=self.max_retries, - ) - else: - self.jira_sysadmin = JIRA( - self.CI_JIRA_URL, logging=False, max_retries=self.max_retries - ) - - if OAUTH: self.jira_normal = JIRA( oauth={ "access_token": "ZVDgYDyIQqJY8IFlQ446jZaURIz5ECiB", @@ -197,6 +154,20 @@ def __init__(self): ) else: if self.CI_JIRA_ADMIN: + self.jira_admin = JIRA( + self.CI_JIRA_URL, + basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), + logging=False, + validate=True, + max_retries=self.max_retries, + ) + self.jira_sysadmin = JIRA( + self.CI_JIRA_URL, + basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), + logging=False, + validate=True, + max_retries=self.max_retries, + ) self.jira_normal = JIRA( self.CI_JIRA_URL, basic_auth=(self.CI_JIRA_USER, self.CI_JIRA_USER_PASSWORD), @@ -205,12 +176,24 @@ def __init__(self): max_retries=self.max_retries, ) else: + self.jira_admin = JIRA( + self.CI_JIRA_URL, + validate=True, + logging=False, + max_retries=self.max_retries, + ) + self.jira_sysadmin = JIRA( + self.CI_JIRA_URL, logging=False, max_retries=self.max_retries + ) self.jira_normal = JIRA( self.CI_JIRA_URL, validate=True, logging=False, max_retries=self.max_retries, ) + if not self.jira_admin.current_user(): + self.initialized = 1 + sys.exit(3) # now we need some data to start with for the tests @@ -225,13 +208,6 @@ def __init__(self): jid length must be less than 9 characters because we may append another one and the Jira Project key length limit is 10. - - Tests run in parallel: - * git branches master or developer, git pr or developers running - tests outside Travis - * Travis is using "Travis" username - - https://docs.travis-ci.com/user/environment-variables/ """ self.jid = get_unique_project_name() @@ -258,7 +234,6 @@ def __init__(self): self.jira_admin.project(self.project_a) except Exception as e: logging.warning(e) - pass else: try: self.jira_admin.delete_project(self.project_a) @@ -269,7 +244,6 @@ def __init__(self): self.jira_admin.project(self.project_b) except Exception as e: logging.warning(e) - pass else: try: self.jira_admin.delete_project(self.project_b) @@ -277,7 +251,7 @@ def __init__(self): pass # wait for the project to be deleted - for i in range(1, 20): + for _ in range(1, 20): try: self.jira_admin.project(self.project_b) except Exception: @@ -285,7 +259,7 @@ def __init__(self): print("Warning: Project not deleted yet....") sleep(2) - for i in range(6): + for _ in range(6): try: if self.jira_admin.create_project( self.project_a, self.project_a_name @@ -2269,9 +2243,7 @@ def test_add_group(self): except JIRAError: pass - sleep( - 2 - ) # avoid 500 errors like https://travis-ci.org/pycontribs/jira/jobs/176544578#L552 + sleep(2) # avoid 500 errors result = self.jira.add_group(self.test_groupname) assert result, True @@ -2288,9 +2260,7 @@ def test_remove_group(self): self._skip_pycontribs_instance() try: self.jira.add_group(self.test_groupname) - sleep( - 1 - ) # avoid 400: https://travis-ci.org/pycontribs/jira/jobs/176539521#L395 + sleep(1) # avoid 400 except JIRAError: pass @@ -2311,9 +2281,7 @@ def test_remove_group(self): ) @not_on_custom_jira_instance - @pytest.mark.xfail( - reason="query may return empty list: https://travis-ci.org/pycontribs/jira/jobs/191274505#L520" - ) + @pytest.mark.xfail(reason="query may return empty list") def test_add_user_to_group(self): try: self.jira.add_user( From e9e0d6084cb02cc442c2067e89468b2149c1bc57 Mon Sep 17 00:00:00 2001 From: studioj <22102283+studioj@users.noreply.github.com> Date: Sat, 15 May 2021 14:09:34 +0200 Subject: [PATCH 043/103] adding version to documentation to help out on #1016 (#1039) * adding version to documentation to help out on #1016 * updating param name from **args to **kwargs and adding some test coverage for it * adding an example in the method documentation Co-authored-by: Neefs, Jef --- docs/api.rst | 5 +++++ jira/resources.py | 36 +++++++++++++++++++++++++++++------- tests/tests.py | 11 +++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index e9259d3bc..b4b2d448b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -26,6 +26,11 @@ Comment .. autoclass:: Comment +Version +======= + +.. autoclass:: jira.resources.Version + Worklog ======= diff --git a/jira/resources.py b/jira/resources.py index 3a41d0cd4..435cce6e7 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -879,10 +879,11 @@ def __init__(self, options, session, raw=None): self._parse_raw(raw) def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): - """Delete this project version from the server. + """ + Delete this project version from the server. - If neither of the arguments are specified, the version is - removed from all issues it is attached to. + If neither of the arguments are specified, the version is removed from all + issues it is attached to. :param moveFixIssuesTo: in issues for which this version is a fix version, add this argument version to the fix version list @@ -898,11 +899,32 @@ def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): return super(Version, self).delete(params) - def update(self, **args): - """Update this project version from the server. It is prior used to archive versions.""" + def update(self, **kwargs): + """ + Update this project version from the server. It is prior used to archive versions. + + Refer to Atlassian REST API `documentation`_. + + .. _documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-project-versions/#api-rest-api-2-version-id-put + + :Example: + + .. code-block:: python + + >> version_id = "10543" + >> version = JIRA("https://atlassian.org").version(version_id) + >> print(version.name) + "some_version_name" + >> version.update(name="another_name") + >> print(version.name) + "another_name" + >> version.update(archived=True) + >> print(version.archived) + True + """ data = {} - for field in args: - data[field] = args[field] + for field in kwargs: + data[field] = kwargs[field] super(Version, self).update(**data) diff --git a/tests/tests.py b/tests/tests.py index 0ce2842d5..c89ee422b 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -1570,6 +1570,17 @@ def test_project_versions(self): i.update(fields={"fixVersions": [{"id": version.id}]}) version.delete() + def test_update_project_version(self): + # given + name = "version-%s" % rndstr() + version = self.jira.create_version(name, self.project_b, "will be deleted soon") + updated_name = "version-%s" % rndstr() + # when + version.update(name=updated_name) + # then + self.assertEqual(updated_name, version.name) + version.delete() + def test_get_project_version_by_name(self): name = "version-%s" % rndstr() version = self.jira.create_version(name, self.project_b, "will be deleted soon") From 16c105302623489992b4e302f247dc73026df787 Mon Sep 17 00:00:00 2001 From: studioj <22102283+studioj@users.noreply.github.com> Date: Sat, 15 May 2021 14:10:01 +0200 Subject: [PATCH 044/103] Fix docstring generation of jira client and minor doc layouting fix (#1038) * some part of the documentation wasn't properly generated. see #1035 * adding warnings about deprecated auth mechanism for jira cloud in examples * this should fix the mismatch between the white background and the line length of the docs Co-authored-by: Neefs, Jef --- docs/_static/css/custom_width.css | 5 +++++ docs/conf.py | 4 +++- docs/examples.rst | 19 ++++++++++++++++++- jira/client.py | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 docs/_static/css/custom_width.css diff --git a/docs/_static/css/custom_width.css b/docs/_static/css/custom_width.css new file mode 100644 index 000000000..f9c89c8d2 --- /dev/null +++ b/docs/_static/css/custom_width.css @@ -0,0 +1,5 @@ +@import url("theme.css"); +/* as found in https://stackoverflow.com/a/62338678/2559785 */ +.wy-nav-content { + max-width: 90%; !important +} diff --git a/docs/conf.py b/docs/conf.py index 75d7ec6a8..a6f27108f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -167,7 +167,7 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -# html_theme_options = {} +html_theme_options = {"body_max_width": "100%"} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] @@ -194,6 +194,8 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] +html_style = "css/custom_width.css" + # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. html_last_updated_fmt = "%b %d, %Y" diff --git a/docs/examples.rst b/docs/examples.rst index df70037ea..d49350dd2 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -54,18 +54,35 @@ Pass a tuple of (username, password) to the ``auth`` constructor argument:: Using this method, authentication happens during the initialization of the object. If the authentication is successful, the retrieved session cookie will be used in future requests. Upon cookie expiration, authentication will happen again transparently. +.. warning:: + This way of authentication is not supported anymore on Jira Cloud. You can find the deprecation notice `here `_. + + For Jira Cloud use the basic_auth= :ref:`basic-auth-api-token` authentication + HTTP BASIC ^^^^^^^^^^ +(username, password) +"""""""""""""""""""" + Pass a tuple of (username, password) to the ``basic_auth`` constructor argument:: auth_jira = JIRA(basic_auth=('username', 'password')) +.. warning:: + This way of authentication is not supported anymore on Jira Cloud. You can find the deprecation notice `here `_ + + For Jira Cloud use the basic_auth= :ref:`basic-auth-api-token` authentication + +.. _basic-auth-api-token: + +(username, api_token) +""""""""""""""""""""" + Or pass a tuple of (email, api_token) to the ``basic_auth`` constructor argument (JIRA cloud):: auth_jira = JIRA(basic_auth=('email', 'API token')) - OAuth ^^^^^ diff --git a/jira/client.py b/jira/client.py index 632f24ae0..b723a33f5 100644 --- a/jira/client.py +++ b/jira/client.py @@ -413,8 +413,8 @@ def __init__( :param async_workers: Set the number of worker threads for async operations. :type async_workers: int :param timeout: Set a read/connect timeout for the underlying calls to Jira (default: None) + Obviously this means that you cannot rely on the return code when this is enabled. :type timeout: Optional[Any] - Obviously this means that you cannot rely on the return code when this is enabled. :param max_retries: Sets the amount Retries for the HTTP sessions initiated by the client. (Default: 3) :type max_retries: int :param proxies: Sets the proxies for the HTTP session. From ea166858eba36c4b1d29e001d3de214a1c44cb35 Mon Sep 17 00:00:00 2001 From: studioj <22102283+studioj@users.noreply.github.com> Date: Sat, 15 May 2021 14:10:47 +0200 Subject: [PATCH 045/103] Remove .idea folder as it messes up pycharm checkouts (intelij idea folder structure has changed since end 2017) (#1037) * this messes up my pycharm project, i think newer versions of handle this file differently https://stackoverflow.com/a/54580525/2559785 * this is covered by black on a different line length * cleaning up gitignore Co-authored-by: Neefs, Jef --- .editorconfig | 7 ------- .gitignore | 7 +------ .idea/codeStyleSettings.xml | 9 --------- 3 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 .editorconfig delete mode 100644 .idea/codeStyleSettings.xml diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index c484f76bf..000000000 --- a/.editorconfig +++ /dev/null @@ -1,7 +0,0 @@ -# EditorConfig is awesome: https://editorconfig.org/ - -# top-most EditorConfig file -root = true - -[*] -max_line_length = 160 diff --git a/.gitignore b/.gitignore index 49840bc00..b44124c28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,4 @@ -# See https://stackoverflow.com/questions/5533050/gitignore-exclude-folder-but-include-specific-subfolder -# to understand pattern used to include .idea/codeStyleSettings.xml but not the rest of .idea/ -!.idea/ -.idea/* -!.idea/codeStyleSettings.xml +.idea *.bak *.egg *.egg-info/ @@ -23,7 +19,6 @@ reports reports/ setenv.sh settings.py -test-quick tests/settings.py tests/test-reports-*/* **/*.log diff --git a/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml deleted file mode 100644 index ae83c6768..000000000 --- a/.idea/codeStyleSettings.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - From 6e513952c854acf67f78bce8c281c0546489a346 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 15 May 2021 13:45:29 +0100 Subject: [PATCH 046/103] add typehints and allow google docstrings (#1023) * chore: allow google docstring * bugfix: respect logging argument * bugfix: correct usage of JIRAError, add typehints * feature: add typehints and coax mypy * example, add _Comment helper * feature: add py.typed for PEP-561 compliance * bugfix: fix epic_id typehint, closes #968 --- MANIFEST.in | 3 + docs/api.rst | 93 +- docs/conf.py | 79 +- examples/basic_auth.py | 7 +- examples/basic_use.py | 9 +- examples/cookie_auth.py | 7 +- jira/client.py | 2615 +++++++++++++++++++++----------------- jira/config.py | 32 +- jira/exceptions.py | 35 +- jira/py.typed | 0 jira/resilientsession.py | 82 +- jira/resources.py | 577 ++++++--- jira/utils/__init__.py | 21 +- setup.cfg | 3 + 14 files changed, 2049 insertions(+), 1514 deletions(-) create mode 100644 jira/py.typed diff --git a/MANIFEST.in b/MANIFEST.in index 5dca7ac7d..bb47213ce 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,8 @@ include LICENSE README.rst +# Include +include jira/py.typed + # Exclude what is in these folders prune tests prune .github diff --git a/docs/api.rst b/docs/api.rst index b4b2d448b..dcd5d9ee8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,47 +1,90 @@ API Documentation ***************** -.. module:: jira .. contents:: Contents :local: -JIRA -==== +jira package +============ -.. autoclass:: JIRA +jira.client module +------------------ -Issue -======== +.. automodule:: jira.client + :members: + :undoc-members: + :show-inheritance: -.. autoclass:: Issue +jira.config module +------------------ -Priority -======== +.. automodule:: jira.config + :members: + :undoc-members: + :show-inheritance: -.. autoclass:: Priority +jira.exceptions module +---------------------- -Comment -======= +.. automodule:: jira.exceptions + :members: + :undoc-members: + :show-inheritance: -.. autoclass:: Comment +jira.jirashell module +--------------------- -Version -======= +.. automodule:: jira.jirashell + :members: + :undoc-members: + :show-inheritance: -.. autoclass:: jira.resources.Version +jira.resilientsession module +---------------------------- -Worklog -======= +.. automodule:: jira.resilientsession + :members: + :undoc-members: + :show-inheritance: -.. autoclass:: Worklog +jira.resources module +--------------------- -Watchers -======== +.. autodata:: jira.client.ResourceType + :annotation: = alias of TypeVar(‘ResourceType’, contravariant=True, bound=jira.resources.Resource) -.. autoclass:: Watchers +.. automodule:: jira.resources + :members: + :undoc-members: + :show-inheritance: + :private-members: -JIRAError -========= +.. autoclass:: jira.resources.StatusCategory + :members: + :undoc-members: + :show-inheritance: -.. autoclass:: JIRAError +.. autoclass:: jira.resources.GreenHopperResource + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: jira.resources.Sprint + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: jira.resources.Board + :members: + :undoc-members: + :show-inheritance: + + +jira.utils module +----------------- + +.. automodule:: jira.utils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py index a6f27108f..720fe07ff 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,75 +25,48 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = "2.2.0" +needs_sphinx = "4.0.0" # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.intersphinx"] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", +] intersphinx_mapping = { - "python": ("https://docs.python.org/3.7", None), - # until https://github.com/psf/requests/issues/5212 is addressed - # "requests": ("http://docs.python-requests.org/en/latest/", None), - "requests": ("https://requests.kennethreitz.org/en/master/", None), + "python": ("https://docs.python.org/3.8", None), + "requests": ("https://requests.readthedocs.io/en/latest/", None), "requests-oauthlib": ("https://requests-oauthlib.readthedocs.io/en/latest/", None), "ipython": ("https://ipython.readthedocs.io/en/stable/", None), "pip": ("https://pip.readthedocs.io/en/stable/", None), } autodoc_default_options = { + "member-order": "bysource", "members": True, - "undoc-members": True, "show-inheritance": True, + "special-members": "__init__", + "undoc-members": True, } +autodoc_inherit_docstrings = False + nitpick_ignore = [ - ("py:class", "Any"), - ("py:class", "Attachment"), - ("py:class", "Board"), - ("py:class", "BufferedReader"), - ("py:class", "Component"), - ("py:class", "CustomFieldOption"), - ("py:class", "Customer"), - ("py:class", "Dashboard"), - ("py:class", "Dict"), - ("py:class", "Filter"), - ("py:class", "Issue._IssueFields"), - ("py:class", "IssueLinkType"), - ("py:class", "IssueType"), - ("py:class", "Iterable"), - ("py:class", "List"), - ("py:class", "NoReturn"), - ("py:class", "Optional"), - ("py:class", "Project"), - ("py:class", "Resolution"), - ("py:class", "Resource"), - ("py:class", "Response"), - ("py:class", "ResultList"), - ("py:class", "ServiceDesk"), - ("py:class", "Sprint"), - ("py:class", "Status"), - ("py:class", "StatusCategory"), - ("py:class", "Tuple"), - ("py:class", "Union"), - ("py:class", "User"), - ("py:class", "Version"), - ("py:class", "Votes"), - ("py:class", "diy"), - ("py:class", "integer"), - ("py:class", "jira.client.ResultList"), - ("py:class", "jira.resources.Resource"), - ("py:class", "jira.resources.Sprint"), - ("py:class", "jira.resources.Watchers"), - ("py:class", "kanban"), - ("py:class", "project"), - ("py:class", "scrum"), - ("py:class", "user"), - ("py:meth", "Resource.delete"), - ("py:meth", "Resource.update"), + ("py:class", "JIRA"), # in jira.resources we only import this class if type + ("py:obj", "typing.ResourceType"), # only Py36 has a problem with this reference + # From other packages ("py:mod", "filemagic"), ("py:mod", "ipython"), ("py:mod", "pip"), + ("py:class", "_io.BufferedReader"), + ("py:class", "BufferedReader"), + ("py:class", "Request"), + ("py:class", "requests.models.Response"), + ("py:class", "requests.sessions.Session"), + ("py:class", "Response"), ("py:mod", "requests-kerberos"), ("py:mod", "requests-oauthlib"), ] @@ -289,6 +262,12 @@ # If true, show URL addresses after external links. # man_show_urls = False +# -- Options for Napoleon ----------------------------------------------------- + +napoleon_google_docstring = True +napoleon_numpy_docstring = False # Explicitly prefer Google style docstring +napoleon_use_param = True # for type hint support + # -- Options for Texinfo output ------------------------------------------------ diff --git a/examples/basic_auth.py b/examples/basic_auth.py index 859aba94d..ccbcdebd5 100644 --- a/examples/basic_auth.py +++ b/examples/basic_auth.py @@ -2,8 +2,11 @@ # username and password over HTTP BASIC authentication. from collections import Counter +from typing import cast from jira import JIRA +from jira.client import ResultList +from jira.resources import Issue # By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK. # See @@ -16,7 +19,9 @@ props = jira.application_properties() # Find all issues reported by the admin -issues = jira.search_issues("assignee=admin") +# Note: we cast() for mypy's benefit, as search_issues can also return the raw json ! +# This is if the following argument is used: `json_result=True` +issues = cast(ResultList[Issue], jira.search_issues("assignee=admin")) # Find the top three projects containing issues reported by admin top_three = Counter([issue.fields.project.key for issue in issues]).most_common(3) diff --git a/examples/basic_use.py b/examples/basic_use.py index 9ab3199f6..b930db688 100644 --- a/examples/basic_use.py +++ b/examples/basic_use.py @@ -6,9 +6,7 @@ # By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK # (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details). -# Override this with the options parameter. -options = {"server": "https://jira.atlassian.com"} -jira = JIRA(options) +jira = JIRA(server="https://jira.atlassian.com") # Get all projects viewable by anonymous users. projects = jira.projects() @@ -18,12 +16,11 @@ # Get an issue. issue = jira.issue("JRA-1330") - # Find all comments made by Atlassians on this issue. atl_comments = [ comment for comment in issue.fields.comment.comments - if re.search(r"@atlassian.com$", comment.author.emailAddress) + if re.search(r"@atlassian.com$", comment.author.key) ] # Add a comment to the issue. @@ -51,4 +48,4 @@ # Linking a remote jira issue (needs applinks to be configured to work) issue = jira.issue("JRA-1330") issue2 = jira.issue("XX-23") # could also be another instance -jira.add_remote_link(issue, issue2) +jira.add_remote_link(issue.id, issue2) diff --git a/examples/cookie_auth.py b/examples/cookie_auth.py index d399fc508..92f731c4a 100644 --- a/examples/cookie_auth.py +++ b/examples/cookie_auth.py @@ -2,8 +2,11 @@ # username and password over HTTP BASIC authentication. from collections import Counter +from typing import cast from jira import JIRA +from jira.client import ResultList +from jira.resources import Issue # By default, the client will connect to a Jira instance started from the Atlassian Plugin SDK. # See @@ -16,11 +19,11 @@ props = jira.application_properties() # Find all issues reported by the admin -issues = jira.search_issues("assignee=admin") +issues = cast(ResultList[Issue], jira.search_issues("assignee=admin")) # Find the top three projects containing issues reported by admin top_three = Counter([issue.fields.project.key for issue in issues]).most_common(3) # import time; time.sleep(65) # Fake cookie expiration -issues = jira.search_issues("assignee=admin") +issues = cast(ResultList[Issue], jira.search_issues("assignee=admin")) diff --git a/jira/client.py b/jira/client.py index b723a33f5..20aef957b 100644 --- a/jira/client.py +++ b/jira/client.py @@ -12,7 +12,7 @@ import hashlib import imghdr import json -import logging +import logging as _logging import mimetypes import os import re @@ -22,11 +22,26 @@ from collections import OrderedDict from collections.abc import Iterable from functools import lru_cache, wraps +from io import BufferedReader from numbers import Number +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Optional, + Tuple, + Type, + TypeVar, + Union, + no_type_check, +) from urllib.parse import urlparse import requests from pkg_resources import parse_version +from requests import Response from requests.auth import AuthBase from requests.utils import get_netrc_auth @@ -84,19 +99,15 @@ pass -logging.getLogger("jira").addHandler(logging.NullHandler()) +LOG = _logging.getLogger("jira") +LOG.addHandler(_logging.NullHandler()) -def translate_resource_args(func): +def translate_resource_args(func: Callable): """Decorator that converts Issue and Project resources to their keys when used as arguments.""" @wraps(func) - def wrapper(*args, **kwargs): - """ - :type args: *Any - :type kwargs: **Any - :return: Any - """ + def wrapper(*args: Any, **kwargs: Any) -> Any: arg_list = [] for arg in args: if isinstance(arg, (Issue, Project)): @@ -109,27 +120,34 @@ def wrapper(*args, **kwargs): return wrapper -def _field_worker(fields=None, **fieldargs): - """ - :type fields: Optional[Dict[str, Any]] - :type fieldargs: **Any - :return: Union[Dict[str, Dict[str, Any]], Dict[str, Dict[str, str]]] - """ +def _field_worker( + fields: Dict[str, Any] = None, **fieldargs: Any +) -> Union[Dict[str, Dict[str, Any]], Dict[str, Dict[str, str]]]: if fields is not None: return {"fields": fields} return {"fields": fieldargs} -class ResultList(list): +ResourceType = TypeVar("ResourceType", contravariant=True, bound=Resource) + + +class ResultList(list, Generic[ResourceType]): def __init__( - self, iterable=None, _startAt=0, _maxResults=0, _total=0, _isLast=None - ): + self, + iterable: Iterable = None, + _startAt: int = 0, + _maxResults: int = 0, + _total: Optional[int] = None, + _isLast: Optional[bool] = None, + ) -> None: """ - :type iterable: Any - :type _startAt: int - :type _maxResults: int - :type _total: int - :type isLast: Optional[bool] + + Args: + iterable (Iterable): [description]. Defaults to None. + _startAt (int): Start page. Defaults to 0. + _maxResults (int): Max results per page. Defaults to 0. + _total (Optional[int]): Total results from query. Defaults to 0. + _isLast (Optional[bool]): Last Page? Defaults to None. """ if iterable is not None: list.__init__(self, iterable) @@ -140,15 +158,12 @@ def __init__( self.maxResults = _maxResults # Optional parameters: self.isLast = _isLast - self.total = _total + self.total = _total if _total is not None else len(self) - self.iterable = iterable or [] + self.iterable: List = list(iterable) if iterable else [] self.current = self.startAt - def __next__(self): - """ - :return: int - """ + def __next__(self) -> Type[ResourceType]: self.current += 1 if self.current > self.total: raise StopIteration @@ -188,7 +203,16 @@ class JiraCookieAuth(AuthBase): """ - def __init__(self, session, _get_session, auth): + def __init__( + self, session: ResilientSession, _get_session: Callable, auth: Tuple[str, str] + ): + """Cookie Based Authentication + + Args: + session (ResilientSession): The Session object to communicate with the API. + _get_session (Callable): The function that returns a :py_class:``User`` + auth (Tuple[str, str]): The username, password tuple + """ self._session = session self._get_session = _get_session self.__auth = auth @@ -233,7 +257,7 @@ class JIRA(object): """User interface to Jira. Clients interact with Jira by constructing an instance of this object and calling its methods. For addressable - resources in Jira -- those with "self" links -- an appropriate subclass of :py:class:`Resource` will be returned + resources in Jira -- those with "self" links -- an appropriate subclass of :py:class:`jira.resources.Resource` will be returned with customized ``update()`` and ``delete()`` methods, along with attribute access to fields. This means that calls of the form ``issue.fields.summary`` will be resolved into the proper lookups to return the JSON value at that mapping. Methods that do not return resources will return a dict constructed from the JSON response or a scalar @@ -250,54 +274,6 @@ class JIRA(object): For quick command line access to a server, see the ``jirashell`` script included with this distribution. The easiest way to instantiate is using ``j = JIRA("https://jira.atlassian.com")`` - - :param options: Specify the server and properties this client will use. Use a dict with any - of the following properties: - - * server -- the server address and context path to use. Defaults to ``http://localhost:2990/jira``. - * rest_path -- the root REST path to use. Defaults to ``api``, where the Jira REST resources live. - * rest_api_version -- the version of the REST resources under rest_path to use. Defaults to ``2``. - * agile_rest_path - the REST path to use for Jira Agile requests. Defaults to ``greenhopper`` (old, private - API). Check `GreenHopperResource` for other supported values. - * verify -- Verify SSL certs. Defaults to ``True``. - * client_cert -- a tuple of (cert,key) for the requests library for client side SSL - * check_update -- Check whether using the newest python-jira library version. - * cookies -- A dict of custom cookies that are sent in all requests to the server. - - :param basic_auth: A tuple of username and password to use when establishing a session via HTTP BASIC - authentication. - :param oauth: A dict of properties for OAuth authentication. The following properties are required: - - * access_token -- OAuth access token for the user - * access_token_secret -- OAuth access token secret to sign with the key - * consumer_key -- key of the OAuth application link defined in Jira - * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to - Jira in the OAuth application link) - - :param kerberos: If true it will enable Kerberos authentication. - :param kerberos_options: A dict of properties for Kerberos authentication. The following properties are possible: - - * mutual_authentication -- string DISABLED or OPTIONAL. - - Example kerberos_options structure: ``{'mutual_authentication': 'DISABLED'}`` - - :param jwt: A dict of properties for JWT authentication supported by Atlassian Connect. The following - properties are required: - - * secret -- shared secret as delivered during 'installed' lifecycle event - (see https://developer.atlassian.com/static/connect/docs/latest/modules/lifecycle.html for details) - * payload -- dict of fields to be inserted in the JWT payload, e.g. 'iss' - - Example jwt structure: ``{'secret': SHARED_SECRET, 'payload': {'iss': PLUGIN_KEY}}`` - - :param validate: If true it will validate your credentials first. Remember that if you are accessing Jira - as anonymous it will fail to instantiate. - :param get_server_info: If true it will fetch server version info first to determine if some API calls - are available. - :param async_: To enable asynchronous requests for those actions where we implemented it, like issue update() or delete(). - :param async_workers: Set the number of worker threads for async operations. - :param timeout: Set a read/connect timeout for the underlying calls to Jira (default: None) - Obviously this means that you cannot rely on the return code when this is enabled. """ DEFAULT_OPTIONS = { @@ -336,22 +312,22 @@ class JIRA(object): def __init__( self, - server=None, - options=None, - basic_auth=None, - oauth=None, - jwt=None, + server: str = None, + options: Dict[str, Union[str, bool, Any]] = None, + basic_auth: Union[None, Tuple[str, str]] = None, + oauth: Dict[str, Any] = None, + jwt: Dict[str, Any] = None, kerberos=False, - kerberos_options=None, + kerberos_options: Dict[str, Any] = None, validate=False, - get_server_info=True, - async_=False, - async_workers=5, - logging=True, - max_retries=3, - proxies=None, - timeout=None, - auth=None, + get_server_info: bool = True, + async_: bool = False, + async_workers: int = 5, + logging: bool = True, + max_retries: int = 3, + proxies: Any = None, + timeout: Optional[Union[Union[float, int], Tuple[float, float]]] = None, + auth: Tuple[str, str] = None, ): """Construct a Jira client instance. @@ -365,64 +341,62 @@ def __init__( For quick command line access to a server, see the ``jirashell`` script included with this distribution. - The easiest way to instantiate is using j = JIRA("https://jira.atlasian.com") - :param server: The server address and context path to use. Defaults to ``http://localhost:2990/jira``. - :type server: Optional[str] - :param options: Specify the server and properties this client will use. Use a dict with any - of the following properties: - * server -- the server address and context path to use. Defaults to ``http://localhost:2990/jira``. - * rest_path -- the root REST path to use. Defaults to ``api``, where the Jira REST resources live. - * rest_api_version -- the version of the REST resources under rest_path to use. Defaults to ``2``. - * agile_rest_path - the REST path to use for Jira Agile requests. Defaults to ``greenhopper`` (old, private - API). Check `GreenHopperResource` for other supported values. - * verify -- Verify SSL certs. Defaults to ``True``. - * client_cert -- a tuple of (cert,key) for the requests library for client side SSL - * check_update -- Check whether using the newest python-jira library version. - :type options: Optional[Dict[str, Any]] - :param basic_auth: A tuple of username and password to use when establishing a session via HTTP BASIC - authentication. - :type basic_auth: Union[Dict, None, Tuple[str, str]] - :param oauth: A dict of properties for OAuth authentication. The following properties are required: - * access_token -- OAuth access token for the user - * access_token_secret -- OAuth access token secret to sign with the key - * consumer_key -- key of the OAuth application link defined in Jira - * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to - Jira in the OAuth application link) - :type oauth: Optional[Any] - :param kerberos: If true it will enable Kerberos authentication. - :type kerberos: bool - :param kerberos_options: A dict of properties for Kerberos authentication. The following properties are possible: - * mutual_authentication -- string DISABLED or OPTIONAL. - Example kerberos_options structure: ``{'mutual_authentication': 'DISABLED'}`` - :type kerberos_options: Optional[Dict[str,str]] - :param jwt: A dict of properties for JWT authentication supported by Atlassian Connect. The following - properties are required: - * secret -- shared secret as delivered during 'installed' lifecycle event - (see https://developer.atlassian.com/static/connect/docs/latest/modules/lifecycle.html for details) - * payload -- dict of fields to be inserted in the JWT payload, e.g. 'iss' - Example jwt structure: ``{'secret': SHARED_SECRET, 'payload': {'iss': PLUGIN_KEY}}`` - :type jwt: Optional[Any] - :param validate: If true it will validate your credentials first. Remember that if you are accessing Jira - as anonymous it will fail to instantiate. - :type validate: bool - :param get_server_info: If true it will fetch server version info first to determine if some API calls - are available. - :type get_server_info: bool - :param async_: To enable async requests for those actions where we implemented it, like issue update() or delete(). - :type async_: bool - :param async_workers: Set the number of worker threads for async operations. - :type async_workers: int - :param timeout: Set a read/connect timeout for the underlying calls to Jira (default: None) - Obviously this means that you cannot rely on the return code when this is enabled. - :type timeout: Optional[Any] - :param max_retries: Sets the amount Retries for the HTTP sessions initiated by the client. (Default: 3) - :type max_retries: int - :param proxies: Sets the proxies for the HTTP session. - :type proxies: Optional[Any] - :param auth: Set a cookie auth token if this is required. - :type auth: Optional[Tuple[str,str]] - :param logging: Determine whether or not logging should be enabled. (Default: True) - :type logging: bool + The easiest way to instantiate is using ``j = JIRA("https://jira.atlasian.com")`` + + Args: + server (Optional[str]): The server address and context path to use. Defaults to ``http://localhost:2990/jira``. + options (Optional[Dict[str, Any]]): Specify the server and properties this client will use. + Use a dict with any of the following properties: + + * server -- the server address and context path to use. Defaults to ``http://localhost:2990/jira``. + * rest_path -- the root REST path to use. Defaults to ``api``, where the Jira REST resources live. + * rest_api_version -- the version of the REST resources under rest_path to use. Defaults to ``2``. + * agile_rest_path - the REST path to use for Jira Agile requests. Defaults to ``greenhopper`` (old, private + API). Check :py:class:`jira.resources.GreenHopperResource` for other supported values. + * verify -- Verify SSL certs. Defaults to ``True``. + * client_cert -- a tuple of (cert,key) for the requests library for client side SSL + * check_update -- Check whether using the newest python-jira library version. + + basic_auth (Union[None, Tuple[str, str]]): A tuple of username and password to use when + establishing a session via HTTP BASIC authentication. + oauth (Optional[Any]): A dict of properties for OAuth authentication. The following properties are required: + + * access_token -- OAuth access token for the user + * access_token_secret -- OAuth access token secret to sign with the key + * consumer_key -- key of the OAuth application link defined in Jira + * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to + Jira in the OAuth application link) + + kerberos (bool): If true it will enable Kerberos authentication. + kerberos_options (Optional[Dict[str,str]]): A dict of properties for Kerberos authentication. + The following properties are possible: + + * mutual_authentication -- string DISABLED or OPTIONAL. + + Example kerberos_options structure: ``{'mutual_authentication': 'DISABLED'}`` + + jwt (Optional[Any]): A dict of properties for JWT authentication supported by Atlassian Connect. + The following properties are required: + + * secret -- shared secret as delivered during 'installed' lifecycle event + (see https://developer.atlassian.com/static/connect/docs/latest/modules/lifecycle.html for details) + * payload -- dict of fields to be inserted in the JWT payload, e.g. 'iss' + + Example jwt structure: ``{'secret': SHARED_SECRET, 'payload': {'iss': PLUGIN_KEY}}`` + + validate (bool): If true it will validate your credentials first. Remember that if you are accessing Jira + as anonymous it will fail to instantiate. + get_server_info (bool): If true it will fetch server version info first to determine if some API calls + are available. + async_ (bool): To enable async requests for those actions where we implemented it, like issue update() or delete(). + async_workers (int): Set the number of worker threads for async operations. + timeout (Optional[Union[Union[float, int], Tuple[float, float]]]): Set a read/connect timeout for the underlying + calls to Jira (default: None). + Obviously this means that you cannot rely on the return code when this is enabled. + max_retries (int): Sets the amount Retries for the HTTP sessions initiated by the client. (Default: 3) + proxies (Optional[Any]): Sets the proxies for the HTTP session. + auth (Optional[Tuple[str,str]]): Set a cookie auth token if this is required. + logging (bool): Determine whether or not logging should be enabled. (Default: True) """ # force a copy of the tuple to be used in __del__() because # sys.version_info could have already been deleted in __del__() @@ -430,13 +404,13 @@ def __init__( if options is None: options = {} - if server and hasattr(server, "keys"): + if server and isinstance(server, dict): warnings.warn( "Old API usage, use JIRA(url) or JIRA(options={'server': url}, when using dictionary always use named parameters.", DeprecationWarning, ) options = server - server = None + server = "" if server: options["server"] = server @@ -444,15 +418,17 @@ def __init__( options["async"] = async_ options["async_workers"] = async_workers - self.logging = logging + LOG.setLevel(_logging.INFO if logging else _logging.CRITICAL) + self.log = LOG - self._options = copy.copy(JIRA.DEFAULT_OPTIONS) + self._options: Dict[str, Any] = copy.copy(JIRA.DEFAULT_OPTIONS) self._options.update(options) self._rank = None # Rip off trailing slash since all urls depend on that + assert isinstance(self._options["server"], str) # to help mypy if self._options["server"].endswith("/"): self._options["server"] = self._options["server"][:-1] @@ -462,6 +438,8 @@ def __init__( self._try_magic() + assert isinstance(self._options["headers"], dict) # for mypy benefit + self._session: ResilientSession # for mypy benefit if oauth: self._create_oauth_session(oauth, timeout) elif basic_auth: @@ -476,9 +454,10 @@ def __init__( # always log in for cookie based auth, as we need a first request to be logged in validate = True else: - verify = self._options["verify"] + verify = bool(self._options["verify"]) self._session = ResilientSession(timeout=timeout) self._session.verify = verify + self._session.headers.update(self._options["headers"]) if "cookies" in self._options: @@ -507,7 +486,7 @@ def __init__( try: self._version = tuple(si["versionNumbers"]) except Exception as e: - logging.error("invalid server_info: %s", si) + self.log.error("invalid server_info: %s", si) raise e self.deploymentType = si.get("deploymentType") else: @@ -524,15 +503,20 @@ def __init__( self._fields[name] = f["id"] @property - def server_url(self): + def server_url(self) -> str: """Return the server url""" - return self._options["server"] + return str(self._options["server"]) - def _create_cookie_auth(self, auth, timeout): + def _create_cookie_auth( + self, + auth: Tuple[str, str], + timeout: Optional[Union[Union[float, int], Tuple[float, float]]], + ): self._session = ResilientSession(timeout=timeout) self._session.auth = JiraCookieAuth(self._session, self.session, auth) - self._session.verify = self._options["verify"] - self._session.cert = self._options["client_cert"] + self._session.verify = bool(self._options["verify"]) + client_cert: Tuple[str, str] = self._options["client_cert"] # to help mypy + self._session.cert = client_cert def _check_update_(self): """Check if the current version of the library is outdated.""" @@ -550,7 +534,7 @@ def _check_update_(self): except requests.RequestException: pass except Exception as e: - logging.warning(e) + self.log.warning(e) def __del__(self): """Destructor for JIRA instance.""" @@ -569,11 +553,11 @@ def close(self): pass self._session = None - def _check_for_html_error(self, content): + def _check_for_html_error(self, content: str): # Jira has the bad habit of returning errors in pages with 200 and # embedding the error in a huge webpage. if "" in content: - logging.warning("Got SecurityTokenMissing") + self.log.warning("Got SecurityTokenMissing") raise JIRAError("SecurityTokenMissing: %s" % content) return False return True @@ -589,35 +573,32 @@ def _get_sprint_field_id(self): def _fetch_pages( self, - item_type, - items_key, - request_path, - startAt=0, - maxResults=50, - params=None, - base=JIRA_BASE_URL, - ): - """Fetch pages. - - :param item_type: Type of single item. ResultList of such items will be returned. - :type item_type: type - :param items_key: Path to the items in JSON returned from server. - Set it to None, if response is an array, and not a JSON object. - :type items_key: Optional[str] - :param request_path: path in request URL - :type request_path: str - :param startAt: index of the first record to be fetched. (Default: 0) - :type startAt: int - :param maxResults: Maximum number of items to return. - If maxResults evaluates as False, it will try to get all items in batches. (Default:50) - :type maxResults: int - :param params: Params to be used in all requests. Should not contain startAt and maxResults, - as they will be added for each request created from this function. - :type params: Dict[str, Any] - :param base: base URL - :type base: str - :rtype: ResultList - """ + item_type: Type[ResourceType], + items_key: Optional[str], + request_path: str, + startAt: int = 0, + maxResults: int = 50, + params: Dict[str, Any] = None, + base: str = JIRA_BASE_URL, + ) -> ResultList[ResourceType]: + """Fetch from a paginated end point. + + Args: + item_type (Type[Resource]): Type of single item. ResultList of such items will be returned. + items_key (Optional[str]): Path to the items in JSON returned from server. + Set it to None, if response is an array, and not a JSON object. + request_path (str): path in request URL + startAt (int): index of the first record to be fetched. (Default: 0) + maxResults (int): Maximum number of items to return. + If maxResults evaluates as False, it will try to get all items in batches. (Default:50) + params (Dict[str, Any]): Params to be used in all requests. Should not contain startAt and maxResults, + as they will be added for each request created from this function. + base (str): base URL to use for the requests. + + Returns: + ResultList + """ + async_workers = None async_class = None if self._options["async"]: try: @@ -626,7 +607,7 @@ def _fetch_pages( async_class = FuturesSession except ImportError: pass - async_workers = self._options["async_workers"] + async_workers = self._options.get("async_workers") page_params = params.copy() if params else {} if startAt: page_params["startAt"] = startAt @@ -641,6 +622,7 @@ def _fetch_pages( if isinstance(resource, dict): total = resource.get("total") + total = int(total) if total is not None else total # 'isLast' is the optional key added to responses in Jira Agile 6.7.6. So far not used in basic Jira API. is_last = resource.get("isLast", False) start_at_from_response = resource.get("startAt", 0) @@ -666,7 +648,7 @@ def _fetch_pages( session=self._session, max_workers=async_workers ) for start_index in range(page_start, total, page_size): - page_params = params.copy() + page_params = params.copy() if params else {} page_params["startAt"] = start_index page_params["maxResults"] = page_size url = self._get_url(request_path) @@ -704,22 +686,22 @@ def _fetch_pages( return ResultList( items, start_at_from_response, max_results_from_response, total, is_last ) - else: - # it seams that search_users can return a list() containing a single user! + else: # TODO: unreachable + # it seems that search_users can return a list() containing a single user! return ResultList( [item_type(self._options, self._session, resource)], 0, 1, 1, True ) - def _get_items_from_page(self, item_type, items_key, resource): - """ - :type item_type: type - :type items_key: str - :type resource: Dict[str, Any] - :rtype: Union[List[Dashboard], List[Issue]] - """ + def _get_items_from_page( + self, + item_type: Type[ResourceType], + items_key: Optional[str], + resource: Dict[str, Any], + ) -> List[ResourceType]: try: return [ - item_type(self._options, self._session, raw_issue_json) + # We need to ignore the type here, as 'Resource' is an option + item_type(self._options, self._session, raw_issue_json) # type: ignore for raw_issue_json in (resource[items_key] if items_key else resource) ] except KeyError as e: @@ -728,13 +710,15 @@ def _get_items_from_page(self, item_type, items_key, resource): # Information about this client - def client_info(self): + def client_info(self) -> str: """Get the server this client is connected to.""" return self.server_url # Universal resource loading - def find(self, resource_format, ids=None): + def find( + self, resource_format: str, ids: Union[Tuple[str, str], int, str] = "" + ) -> Resource: """Find Resource object for any addressable resource on the server. This method is a universal resource locator for any REST-ful resource in Jira. The @@ -749,23 +733,25 @@ def find(self, resource_format, ids=None): reason, it is intended to support resources that are not included in the standard Atlassian REST API. - :param resource_format: the subpath to the resource string - :type resource_format: str - :param ids: values to substitute in the ``resource_format`` string - :type ids: tuple or None - :rtype: Resource + Args: + resource_format (str): the subpath to the resource string + ids (Optional[Tuple]): values to substitute in the ``resource_format`` string + Returns: + Resource """ resource = Resource(resource_format, self._options, self._session) resource.find(ids) return resource - def async_do(self, size=10): + @no_type_check # FIXME: This function fails type checking, probably a bug or two + def async_do(self, size: int = 10): """Execute all asynchronous jobs and wait for them to finish. By default it will run on 10 threads. - :param size: number of threads to run on. + Args: + size (int): number of threads to run on. """ if hasattr(self._session, "_async_jobs"): - logging.info( + self.log.info( "Executing asynchronous %s jobs found in queue by using %s threads..." % (len(self._session._async_jobs), size) ) @@ -774,36 +760,39 @@ def async_do(self, size=10): # Application properties # non-resource - def application_properties(self, key=None): + def application_properties( + self, key: str = None + ) -> Union[Dict[str, str], List[Dict[str, str]]]: """Return the mutable server application properties. - :param key: the single property to return a value for - :type key: Optional[str] - :rtype: Union[Dict[str, str], List[Dict[str, str]]] - + Args: + key (Optional[str]): the single property to return a value for + Returns: + Union[Dict[str, str], List[Dict[str, str]]] """ params = {} if key is not None: params["key"] = key return self._get_json("application-properties", params=params) - def set_application_property(self, key, value): + def set_application_property(self, key: str, value: str): """Set the application property. - :param key: key of the property to set - :type key: str - :param value: value to assign to the property - :type value: str + Args: + key (str): key of the property to set + value (str): value to assign to the property """ url = self._get_latest_url("application-properties/" + key) payload = {"id": key, "value": value} return self._session.put(url, data=json.dumps(payload)) - def applicationlinks(self, cached=True): + def applicationlinks(self, cached: bool = True) -> List: """List of application links. - :return: json + Returns: + List[Dict]: json, or empty list """ + self._applicationlinks: List[Dict] # for mypy benefit # if cached, return the last result if cached and hasattr(self, "_applicationlinks"): return self._applicationlinks @@ -814,55 +803,56 @@ def applicationlinks(self, cached=True): r = self._session.get(url) o = json_loads(r) - if "list" in o: + if "list" in o and isinstance(o, dict): self._applicationlinks = o["list"] else: self._applicationlinks = [] return self._applicationlinks # Attachments - def attachment(self, id): + def attachment(self, id: str) -> Attachment: """Get an attachment Resource from the server for the specified ID. - :param id: The Attachment ID - :type id: str - :rtype: Attachment + Args: + id (str): The Attachment ID + + Returns: + Attachment """ return self._find_for_resource(Attachment, id) # non-resource - def attachment_meta(self): + def attachment_meta(self) -> Dict[str, int]: """Get the attachment metadata. - :rtype: Dict[str, int] + Return: + Dict[str, int] """ return self._get_json("attachment/meta") @translate_resource_args - def add_attachment(self, issue, attachment, filename=None): + def add_attachment( + self, issue: str, attachment: Union[str, BufferedReader], filename: str = None + ) -> Attachment: """Attach an attachment to an issue and returns a Resource for it. The client will *not* attempt to open or validate the attachment; it expects a file-like object to be ready for its use. The user is still responsible for tidying up (e.g., closing the file, killing the socket, etc.) - :param issue: the issue to attach the attachment to - :type issue: str - :param attachment: file-like object to attach to the issue, also works if it is a string with the filename. - :type attachment: BufferedReader - :param filename: optional name for the attached file. If omitted, the file object's ``name`` attribute - is used. If you acquired the file-like object by any other method than ``open()``, make sure - that a name is specified in one way or the other. - :type filename: str - :rtype: Attachment + Args: + issue (str): the issue to attach the attachment to + attachment (Union[str,BufferedReader]): file-like object to attach to the issue, also works if it is a string with the filename. + filename (str): optional name for the attached file. If omitted, the file object's ``name`` attribute + is used. If you acquired the file-like object by any other method than ``open()``, make sure + that a name is specified in one way or the other. + + Returns: + Attachment """ if isinstance(attachment, str): - attachment = open(attachment, "rb") - if ( - hasattr(attachment, "read") - and hasattr(attachment, "mode") - and attachment.mode != "rb" - ): - logging.warning( + attachment: BufferedReader = open(attachment, "rb") # type: ignore + elif isinstance(attachment, BufferedReader) and attachment.mode != "rb": + self.log.warning( "%s was not opened in 'rb' mode, attaching file may fail." % attachment.name ) @@ -870,7 +860,7 @@ def add_attachment(self, issue, attachment, filename=None): url = self._get_url("issue/" + str(issue) + "/attachments") fname = filename - if not fname: + if not fname and isinstance(attachment, BufferedReader): fname = os.path.basename(attachment.name) if "MultipartEncoder" not in globals(): @@ -885,11 +875,8 @@ def add_attachment(self, issue, attachment, filename=None): else: method = "MultipartEncoder" - def file_stream(): - """Returns files stream of attachment. - - :rtype: MultipartEncoder - """ + def file_stream() -> MultipartEncoder: + """Returns files stream of attachment.""" return MultipartEncoder( fields={"file": (fname, attachment, "application/octet-stream")} ) @@ -904,61 +891,63 @@ def file_stream(): retry_data=file_stream, ) - js = json_loads(r) + js: Union[Dict[str, Any], List[Dict[str, Any]]] = json_loads(r) if not js or not isinstance(js, Iterable): raise JIRAError("Unable to parse JSON: %s" % js) - attachment = Attachment(self._options, self._session, js[0]) - if attachment.size == 0: + jira_attachment = Attachment( + self._options, self._session, js[0] if isinstance(js, List) else js + ) + if jira_attachment.size == 0: raise JIRAError( "Added empty attachment via %s method?!: r: %s\nattachment: %s" - % (method, r, attachment) + % (method, r, jira_attachment) ) - return attachment + return jira_attachment - def delete_attachment(self, id): + def delete_attachment(self, id: str) -> Response: """Delete attachment by id. - :param id: ID of the attachment to delete - :type id: str + Args: + id (str): ID of the attachment to delete + + Returns: + Response """ url = self._get_url("attachment/" + str(id)) return self._session.delete(url) # Components - def component(self, id): + def component(self, id: str): """Get a component Resource from the server. - :param id: ID of the component to get - :type id: str + Args: + id (str): ID of the component to get """ return self._find_for_resource(Component, id) @translate_resource_args def create_component( self, - name, - project, + name: str, + project: str, description=None, leadUserName=None, assigneeType=None, isAssigneeTypeValid=False, - ): + ) -> Component: """Create a component inside a project and return a Resource for it. - :param name: name of the component - :type name: str - :param project: key of the project to create the component in - :type project: str - :param description: a description of the component - :type description: str - :param leadUserName: the username of the user responsible for this component - :type leadUserName: Optional[str] - :param assigneeType: see the ComponentBean.AssigneeType class for valid values - :type assigneeType: Optional[str] - :param isAssigneeTypeValid: boolean specifying whether the assignee type is acceptable (Default: False) - :type isAssigneeTypeValid: bool - :rtype: Component + Args: + name (str): name of the component + project (str): key of the project to create the component in + description (str): a description of the component + leadUserName (Optional[str]): the username of the user responsible for this component + assigneeType (Optional[str]): see the ComponentBean.AssigneeType class for valid values + isAssigneeTypeValid (bool): boolean specifying whether the assignee type is acceptable (Default: False) + + Returns: + Component """ data = { "name": name, @@ -978,49 +967,56 @@ def create_component( component = Component(self._options, self._session, raw=json_loads(r)) return component - def component_count_related_issues(self, id): + def component_count_related_issues(self, id: str): """Get the count of related issues for a component. - :type id: integer - :param id: ID of the component to use + Args: + id (str): ID of the component to use """ - return self._get_json("component/" + id + "/relatedIssueCounts")["issueCount"] + data: Dict[str, Any] = self._get_json( + "component/" + str(id) + "/relatedIssueCounts" + ) + return data["issueCount"] - def delete_component(self, id): + def delete_component(self, id: str) -> Response: """Delete component by id. - :param id: ID of the component to use - :type id: str - :rtype: Response + Args: + id (str): ID of the component to use + + Returns: + Response """ url = self._get_url("component/" + str(id)) return self._session.delete(url) # Custom field options - def custom_field_option(self, id): + def custom_field_option(self, id: str) -> CustomFieldOption: """Get a custom field option Resource from the server. - :param id: ID of the custom field to use - :type id: str - :rtype: CustomFieldOption + Args: + id (str): ID of the custom field to use + + Returns: + CustomFieldOption """ return self._find_for_resource(CustomFieldOption, id) # Dashboards - def dashboards(self, filter=None, startAt=0, maxResults=20): + def dashboards( + self, filter=None, startAt=0, maxResults=20 + ) -> ResultList[Dashboard]: """Return a ResultList of Dashboard resources and a ``total`` count. - :param filter: either "favourite" or "my", the type of dashboards to return - :type filter: Optional[str] - :param startAt: index of the first dashboard to return (Default: 0) - :type startAt: int - :param maxResults: maximum number of dashboards to return. - If maxResults evaluates as False, it will try to get all items in batches. (Default: 20) - :type maxResults: int + Args: + filter (Optional[str]): either "favourite" or "my", the type of dashboards to return + startAt (int): index of the first dashboard to return (Default: 0) + maxResults (int): maximum number of dashboards to return. If maxResults evaluates as False, it will try to get all items in batches. (Default: 20) - :rtype: ResultList + Returns: + ResultList """ params = {} if filter is not None: @@ -1029,63 +1025,74 @@ def dashboards(self, filter=None, startAt=0, maxResults=20): Dashboard, "dashboards", "dashboard", startAt, maxResults, params ) - def dashboard(self, id): + def dashboard(self, id: str) -> Dashboard: """Get a dashboard Resource from the server. - :param id: ID of the dashboard to get. - :type id: str - :rtype: Dashboard + Args: + id (str): ID of the dashboard to get. + + Returns: + Dashboard """ return self._find_for_resource(Dashboard, id) # Fields # non-resource - def fields(self): + def fields(self) -> List[Dict[str, Any]]: """Return a list of all issue fields. - :rtype: List[Dict[str, Any]] + Returns: + List[Dict[str, Any]] """ return self._get_json("field") # Filters - def filter(self, id): + def filter(self, id: str) -> Filter: """Get a filter Resource from the server. - :param id: ID of the filter to get. - :type id: str - :rtype: Filter + Args: + id (str): ID of the filter to get. + + Returns: + Filter """ return self._find_for_resource(Filter, id) - def favourite_filters(self): + def favourite_filters(self) -> List[Filter]: """Get a list of filter Resources which are the favourites of the currently authenticated user. - :rtype: List[Filter] + Returns: + List[Filter] """ - r_json = self._get_json("filter/favourite") + r_json: List[Dict[str, Any]] = self._get_json("filter/favourite") filters = [ Filter(self._options, self._session, raw_filter_json) for raw_filter_json in r_json ] return filters - def create_filter(self, name=None, description=None, jql=None, favourite=None): + def create_filter( + self, + name: str = None, + description: str = None, + jql: str = None, + favourite: bool = None, + ): """Create a new filter and return a filter Resource for it. - :param name: name of the new filter - :type name: str - :param description: useful human readable description of the new filter - :type description: str - :param jql: query string that defines the filter - :type jql: str - :param favourite: whether to add this filter to the current user's favorites - :type favourite: bool - :rtype: Filter + Args: + name (str): name of the new filter + description (str): useful human readable description of the new filter + jql (str): query string that defines the filter + favourite (bool): whether to add this filter to the current user's favorites + + Returns: + Filter """ - data = {} + data: Dict[str, Any] = {} if name is not None: data["name"] = name if description is not None: @@ -1097,22 +1104,24 @@ def create_filter(self, name=None, description=None, jql=None, favourite=None): url = self._get_url("filter") r = self._session.post(url, data=json.dumps(data)) - raw_filter_json = json_loads(r) + raw_filter_json: Dict[str, Any] = json_loads(r) return Filter(self._options, self._session, raw=raw_filter_json) def update_filter( - self, filter_id, name=None, description=None, jql=None, favourite=None + self, + filter_id, + name: str = None, + description: str = None, + jql: str = None, + favourite: bool = None, ): """Update a filter and return a filter Resource for it. - :param name: name of the new filter - :type name: Optional[str] - :param description: useful human readable description of the new filter - :type description: Optional[str] - :param jql: query string that defines the filter - :type jql: Optional[str] - :param favourite: whether to add this filter to the current user's favorites - :type favourite: Optional[bool] + Args: + name (Optional[str]): name of the new filter + description (Optional[str]): useful human readable description of the new filter + jql (Optional[str]): query string that defines the filter + favourite (Optional[bool]): whether to add this filter to the current user's favorites """ filter = self.filter(filter_id) @@ -1132,15 +1141,15 @@ def update_filter( # Groups - def group(self, id, expand=None): + def group(self, id: str, expand: Any = None) -> Group: """Get a group Resource from the server. - :param id: ID of the group to get - :param id: str - :param expand: Extra information to fetch inside each resource - :type expand: Optional[Any] + Args: + id (str): ID of the group to get + expand (Optional[Any]): Extra information to fetch inside each resource - :rtype: User + Returns: + Group """ group = Group(self._options, self._session) params = {} @@ -1150,19 +1159,23 @@ def group(self, id, expand=None): return group # non-resource - def groups(self, query=None, exclude=None, maxResults=9999): + def groups( + self, + query: Optional[str] = None, + exclude: Optional[Any] = None, + maxResults: int = 9999, + ) -> List[str]: """Return a list of groups matching the specified criteria. - :param query: filter groups by name with this string - :type query: Optional[str] - :param exclude: filter out groups by name with this string - :type exclude: Optional[Any] - :param maxResults: maximum results to return. (Default: 9999) - :type maxResults: int - :rtype: List[str] + Args: + query (Optional[str]): filter groups by name with this string + exclude (Optional[Any]): filter out groups by name with this string + maxResults (int): maximum results to return. (Default: 9999) + Returns: + List[str] """ - params = {} + params: Dict[str, Any] = {} groups = [] if query is not None: params["query"] = query @@ -1174,11 +1187,11 @@ def groups(self, query=None, exclude=None, maxResults=9999): groups.append(group["name"]) return sorted(groups) - def group_members(self, group): + def group_members(self, group: str) -> OrderedDict: """Return a hash or users with their information. Requires Jira 6.0 or will raise NotImplemented. - :param group: Name of the group. - :type group: str + Args: + group (str): Name of the group. """ if self._version < (6, 0, 0): raise NotImplementedError( @@ -1211,13 +1224,14 @@ def group_members(self, group): } return OrderedDict(sorted(result.items(), key=lambda t: t[0])) - def add_group(self, groupname): + def add_group(self, groupname: str) -> bool: """Create a new group in Jira. - :param groupname: The name of the group you wish to create. - :type groupname: str - :return: Boolean - True if successful. - :rtype: bool + Args: + groupname (str): The name of the group you wish to create. + + Returns: + bool: True if successful. """ url = self._get_latest_url("group") @@ -1234,13 +1248,14 @@ def add_group(self, groupname): return True - def remove_group(self, groupname): + def remove_group(self, groupname: str) -> bool: """Delete a group from the Jira instance. - :param groupname: The group to be deleted from the Jira instance. - :type groupname: str - :return: Boolean. Returns True on success. - :rtype: bool + Args: + groupname (str): The group to be deleted from the Jira instance. + + Returns: + bool: Returns True on success. """ # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 @@ -1251,16 +1266,20 @@ def remove_group(self, groupname): # Issues - def issue(self, id, fields=None, expand=None): + def issue( + self, + id: Union[Issue, str], + fields: Optional[str] = None, + expand: Optional[str] = None, + ) -> Issue: """Get an issue Resource from the server. - :param id: ID or key of the issue to get - :type id: Union[Issue, str] - :param fields: comma-separated string of issue fields to include in the results - :type fields: Optional[str] - :param expand: extra information to fetch inside each resource - :type expand: Optional[str] - :rtype: Issue + Args: + id (Union[Issue, str]): ID or key of the issue to get + fields (Optional[str]): comma-separated string of issue fields to include in the results + expand (Optional[str]): extra information to fetch inside each resource + Returns: + Issue """ # this allows us to pass Issue objects to issue() if isinstance(id, Issue): @@ -1276,7 +1295,12 @@ def issue(self, id, fields=None, expand=None): issue.find(id, params=params) return issue - def create_issue(self, fields=None, prefetch=True, **fieldargs): + def create_issue( + self, + fields: Optional[Dict[str, Any]] = None, + prefetch: bool = True, + **fieldargs + ) -> Issue: """Create a new issue and return an issue Resource for it. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value @@ -1290,66 +1314,70 @@ def create_issue(self, fields=None, prefetch=True, **fieldargs): fields in a new issue. This information is available through the 'createmeta' method. Further examples are available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue - :param fields: a dict containing field names and the values to use. If present, all other keyword arguments - will be ignored - :type fields: Optional[Dict[str, Any]] - :param prefetch: whether to reload the created issue Resource so that all of its data is present in the value - returned from this method - :type prefetch: bool - :rtype: Issue + Args: + fields (Optional[Dict[str, Any]]): a dict containing field names and the values to use. If present, all other keyword arguments + will be ignored + prefetch (bool): whether to reload the created issue Resource so that all of its data is present in the value + returned from this method + Returns: + Issue """ - data = _field_worker(fields, **fieldargs) + data: Dict[str, Any] = _field_worker(fields, **fieldargs) p = data["fields"]["project"] if isinstance(p, str) or isinstance(p, int): - data["fields"]["project"] = {"id": self.project(p).id} + data["fields"]["project"] = {"id": self.project(str(p)).id} p = data["fields"]["issuetype"] if isinstance(p, int): data["fields"]["issuetype"] = {"id": p} if isinstance(p, str) or isinstance(p, int): - data["fields"]["issuetype"] = {"id": self.issue_type_by_name(p).id} + data["fields"]["issuetype"] = {"id": self.issue_type_by_name(str(p)).id} url = self._get_url("issue") r = self._session.post(url, data=json.dumps(data)) raw_issue_json = json_loads(r) if "key" not in raw_issue_json: - raise JIRAError(r.status_code, response=r, url=url, text=json.dumps(data)) + raise JIRAError( + status_code=r.status_code, response=r, url=url, text=json.dumps(data) + ) if prefetch: return self.issue(raw_issue_json["key"]) else: return Issue(self._options, self._session, raw=raw_issue_json) - def create_issues(self, field_list, prefetch=True): + def create_issues( + self, field_list: List[Dict[str, Any]], prefetch: bool = True + ) -> List[Dict[str, Any]]: """Bulk create new issues and return an issue Resource for each successfully created issue. See `create_issue` documentation for field information. - :param field_list: a list of dicts each containing field names and the values to use. Each dict - is an individual issue to create and is subject to its minimum requirements. - :type field_list: List[Dict[str, Any]] - :param prefetch: whether to reload the created issue Resource for each created issue so that all - of its data is present in the value returned from this method. - :type prefetch: bool - :rtype: List[Dict[str, Any]] + Args: + field_list (List[Dict[str, Any]]): a list of dicts each containing field names and the values to use. Each dict + is an individual issue to create and is subject to its minimum requirements. + prefetch (bool): whether to reload the created issue Resource for each created issue so that all + of its data is present in the value returned from this method. + Returns: + List[Dict[str, Any]] """ - data = {"issueUpdates": []} + data: Dict[str, List] = {"issueUpdates": []} for field_dict in field_list: - issue_data = _field_worker(field_dict) + issue_data: Dict[str, Any] = _field_worker(field_dict) p = issue_data["fields"]["project"] if isinstance(p, str) or isinstance(p, int): - issue_data["fields"]["project"] = {"id": self.project(p).id} + issue_data["fields"]["project"] = {"id": self.project(str(p)).id} p = issue_data["fields"]["issuetype"] if isinstance(p, int): issue_data["fields"]["issuetype"] = {"id": p} if isinstance(p, str) or isinstance(p, int): issue_data["fields"]["issuetype"] = { - "id": self.issue_type_by_name(p).id + "id": self.issue_type_by_name(str(p)).id } data["issueUpdates"].append(issue_data) @@ -1360,7 +1388,7 @@ def create_issues(self, field_list, prefetch=True): raw_issue_json = json_loads(r) # Catching case where none of the issues has been created. See https://github.com/pycontribs/jira/issues/350 except JIRAError as je: - if je.status_code == 400: + if je.status_code == 400 and je.response: raw_issue_json = json.loads(je.response.text) else: raise @@ -1397,7 +1425,8 @@ def create_issues(self, field_list, prefetch=True): def supports_service_desk(self): """Returns whether or not the Jira instance supports service desk. - :rtype: bool + Returns: + bool """ url = self.server_url + "/rest/servicedeskapi/info" headers = {"X-ExperimentalApi": "opt-in"} @@ -1407,14 +1436,14 @@ def supports_service_desk(self): except JIRAError: return False - def create_customer(self, email, displayName): + def create_customer(self, email: str, displayName: str) -> Customer: """Create a new customer and return an issue Resource for it. - :param email: Customer Email - :type email: str - :param displayName: Customer display name - :type displayName: str - :rtype: Customer + Args: + email (str): Customer Email + displayName (str): Customer display name + Returns: + Customer """ url = self.server_url + "/rest/servicedeskapi/customer" @@ -1428,13 +1457,14 @@ def create_customer(self, email, displayName): raw_customer_json = json_loads(r) if r.status_code != 201: - raise JIRAError(r.status_code, request=r) + raise JIRAError(status_code=r.status_code, request=r) return Customer(self._options, self._session, raw=raw_customer_json) - def service_desks(self): + def service_desks(self) -> List[ServiceDesk]: """Get a list of ServiceDesk Resources from the server visible to the current authenticated user. - :rtype: List[ServiceDesk] + Returns: + List[ServiceDesk] """ url = self.server_url + "/rest/servicedeskapi/servicedesk" @@ -1447,17 +1477,22 @@ def service_desks(self): ] return projects - def service_desk(self, id): + def service_desk(self, id: str) -> ServiceDesk: """Get a Service Desk Resource from the server. - :param id: ID or key of the Service Desk to get - :type id: str - :rtype: ServiceDesk + Args: + id (str): ID or key of the Service Desk to get + + Returns: + ServiceDesk """ return self._find_for_resource(ServiceDesk, id) - def create_customer_request(self, fields=None, prefetch=True, **fieldargs): + @no_type_check # FIXME: This function does not do what it wants to with fieldargs + def create_customer_request( + self, fields: Dict[str, Any] = None, prefetch: bool = True, **fieldargs + ) -> Issue: """Create a new customer request and return an issue Resource for it. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value @@ -1471,13 +1506,13 @@ def create_customer_request(self, fields=None, prefetch=True, **fieldargs): fields in a new issue. This information is available through the 'createmeta' method. Further examples are available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue - :param fields: a dict containing field names and the values to use. If present, all other keyword arguments - will be ignored - :type fields: Dict[str, Any] - :param prefetch: whether to reload the created issue Resource so that all of its data is present in the value - returned from this method - :type prefetch: bool - :rtype: Issue + Args: + fields (Dict[str, Any]): a dict containing field names and the values to use. If present, all other keyword arguments + will be ignored + prefetch (bool): whether to reload the created issue Resource so that all of its data is present in the value + returned from this method + Returns: + Issue """ data = fields @@ -1503,7 +1538,7 @@ def create_customer_request(self, fields=None, prefetch=True, **fieldargs): raw_issue_json = json_loads(r) if "issueKey" not in raw_issue_json: - raise JIRAError(r.status_code, request=r) + raise JIRAError(status_code=r.status_code, request=r) if prefetch: return self.issue(raw_issue_json["issueKey"]) else: @@ -1511,36 +1546,33 @@ def create_customer_request(self, fields=None, prefetch=True, **fieldargs): def createmeta( self, - projectKeys=None, - projectIds=[], - issuetypeIds=None, - issuetypeNames=None, - expand=None, - ): + projectKeys: Optional[Union[Tuple[str, str], str]] = None, + projectIds: Union[List, Tuple[str, str]] = [], + issuetypeIds: Optional[List[str]] = None, + issuetypeNames: Optional[str] = None, + expand: Optional[str] = None, + ) -> Dict[str, Any]: """Get the metadata required to create issues, optionally filtered by projects and issue types. - :param projectKeys: keys of the projects to filter the results with. - Can be a single value or a comma-delimited string. May be combined - with projectIds. - :type projectKeys: Union[None, Tuple[str, str], str] - :param projectIds: IDs of the projects to filter the results with. Can - be a single value or a comma-delimited string. May be combined with - projectKeys. - :type projectIds: Union[List, Tuple[str, str]] - :param issuetypeIds: IDs of the issue types to filter the results with. - Can be a single value or a comma-delimited string. May be combined - with issuetypeNames. - :type issuetypeIds: Optional[List[str]] - :param issuetypeNames: Names of the issue types to filter the results - with. Can be a single value or a comma-delimited string. May be - combined with issuetypeIds. - :type issuetypeNames: Optional[str] - :param expand: extra information to fetch inside each resource. - :type expand: Optional[str] - :rtype: Dict[str, Any] - - """ - params = {} + Args: + projectKeys (Optional[Union[Tuple[str, str], str]]): keys of the projects to filter the results with. + Can be a single value or a comma-delimited string. May be combined + with projectIds. + projectIds (Union[List, Tuple[str, str]]): IDs of the projects to filter the results with. Can + be a single value or a comma-delimited string. May be combined with + projectKeys. + issuetypeIds (Optional[List[str]]): IDs of the issue types to filter the results with. + Can be a single value or a comma-delimited string. May be combined + with issuetypeNames. + issuetypeNames (Optional[str]): Names of the issue types to filter the results + with. Can be a single value or a comma-delimited string. May be + combined with issuetypeIds. + expand (Optional[str]): extra information to fetch inside each resource. + Returns: + Dict[str, Any] + + """ + params: Dict[str, Any] = {} if projectKeys is not None: params["projectKeys"] = projectKeys if projectIds is not None: @@ -1555,25 +1587,25 @@ def createmeta( params["expand"] = expand return self._get_json("issue/createmeta", params) - def _get_user_key(self, user): + def _get_user_key(self, user: str) -> str: """Internal method for translating an user (str) to an key.""" try: key = self.search_users(user, maxResults=1)[0].key except Exception as e: - raise JIRAError(e) + raise JIRAError(str(e)) return key # non-resource @translate_resource_args - def assign_issue(self, issue, assignee): + def assign_issue(self, issue: Union[int, str], assignee: str) -> bool: """Assign an issue to a user. None will set it to unassigned. -1 will set it to Automatic. - :param issue: the issue ID or key to assign - :type issue: int or str - :param assignee: the user to assign the issue to - :type assignee: str + Args: + issue (Union[int,str]): the issue ID or key to assign + assignee (str): the user to assign the issue to - :rtype: bool + Returns: + bool """ url = self._get_latest_url("issue/{}/assignee".format(str(issue))) payload = {"name": self._get_user_key(assignee)} @@ -1583,12 +1615,13 @@ def assign_issue(self, issue, assignee): return True @translate_resource_args - def comments(self, issue): + def comments(self, issue: str) -> List[Comment]: """Get a list of comment Resources. - :param issue: the issue to get comments from - :type issue: str - :rtype: List[Comment] + Args: + issue (str): the issue to get comments from + Returns: + List[Comment] """ r_json = self._get_json("issue/{}/comment".format(str(issue))) @@ -1599,35 +1632,41 @@ def comments(self, issue): return comments @translate_resource_args - def comment(self, issue, comment): + def comment(self, issue: str, comment: str) -> Comment: """Get a comment Resource from the server for the specified ID. - :param issue: ID or key of the issue to get the comment from - :param comment: ID of the comment to get + Args: + issue (str): ID or key of the issue to get the comment from + comment (str): ID of the comment to get """ return self._find_for_resource(Comment, (issue, comment)) @translate_resource_args - def add_comment(self, issue, body, visibility=None, is_internal=False): + def add_comment( + self, + issue: str, + body: str, + visibility: Optional[Dict[str, str]] = None, + is_internal: bool = False, + ) -> Comment: """Add a comment from the current authenticated user on the specified issue and return a Resource for it. The issue identifier and comment body are required. - :param issue: ID or key of the issue to add the comment to - :type issue: str - :param body: Text of the comment to add - :type body: str - :param visibility: a dict containing two entries: "type" and "value". - "type" is 'role' (or 'group' if the Jira server has configured - comment visibility for groups) and 'value' is the name of the role - (or group) to which viewing of this comment will be restricted. - :type visibility: Optional[Dict[str, str]] - :param is_internal: Defines whether a comment has to be marked as 'Internal' in Jira Service Desk (Default: False) - :type is_internal: bool - :rtype: Comment + Args: + issue (str): ID or key of the issue to add the comment to + body (str): Text of the comment to add + visibility (Optional[Dict[str, str]]): a dict containing two entries: "type" and "value". + "type" is 'role' (or 'group' if the Jira server has configured + comment visibility for groups) and 'value' is the name of the role + (or group) to which viewing of this comment will be restricted. + is_internal (bool): Defines whether a comment has to be marked as 'Internal' in Jira Service Desk (Default: False) + + Returns: + Comment: the created comment """ - data = {"body": body} + data: Dict[str, Any] = {"body": body} if is_internal: data.update( @@ -1649,20 +1688,24 @@ def add_comment(self, issue, body, visibility=None, is_internal=False): # non-resource @translate_resource_args - def editmeta(self, issue): + def editmeta(self, issue: Union[str, int]): """Get the edit metadata for an issue. - :param issue: the issue to get metadata for - :rtype: Dict[str, Dict[str, Dict[str, Any]]] + Args: + issue (str): the issue to get metadata for + + Returns: + Dict[str, Dict[str, Dict[str, Any]]] """ return self._get_json("issue/" + str(issue) + "/editmeta") @translate_resource_args - def remote_links(self, issue): + def remote_links(self, issue: Union[str, int]) -> List[RemoteLink]: """Get a list of remote link Resources from an issue. - :param issue: the issue to get remote links from + Args: + issue (str): the issue to get remote links from """ r_json = self._get_json("issue/" + str(issue) + "/remotelink") remote_links = [ @@ -1672,19 +1715,25 @@ def remote_links(self, issue): return remote_links @translate_resource_args - def remote_link(self, issue, id): + def remote_link(self, issue: str, id: str) -> RemoteLink: """Get a remote link Resource from the server. - :param issue: the issue holding the remote link - :param id: ID of the remote link + Args: + issue (str): the issue holding the remote link + id (str): ID of the remote link """ return self._find_for_resource(RemoteLink, (issue, id)) # removed the @translate_resource_args because it prevents us from finding # information for building a proper link def add_remote_link( - self, issue, destination, globalId=None, application=None, relationship=None - ): + self, + issue: str, + destination: Union[Issue, Dict[str, Any]], + globalId: Optional[str] = None, + application: Optional[Dict[str, Any]] = None, + relationship: Optional[str] = None, + ) -> RemoteLink: """Add a remote link from an issue to an external application and returns a remote link Resource for it. ``destination`` should be a dict containing at least ``url`` to the linked external URL and @@ -1693,14 +1742,18 @@ def add_remote_link( For definitions of the allowable fields for ``object`` and the keyword arguments ``globalId``, ``application`` and ``relationship``, see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links. - :param issue: the issue to add the remote link to - :param destination: the link details to add (see the above link for details) - :param globalId: unique ID for the link (see the above link for details) - :param application: application information for the link (see the above link for details) - :param relationship: relationship description for the link (see the above link for details) + Args: + issue (str): the issue to add the remote link to + destination (Union[Issue, Dict[str, Any]]): the link details to add (see the above link for details) + globalId (Optional[str]): unique ID for the link (see the above link for details) + application (Optional[Dict[str,Any]]): application information for the link (see the above link for details) + relationship (Optional[str]): relationship description for the link (see the above link for details) + + Returns: + RemoteLink: the added remote lint """ try: - applicationlinks = self.applicationlinks() + applicationlinks: List[Dict] = self.applicationlinks() except JIRAError as e: applicationlinks = [] # In many (if not most) configurations, non-admin users are @@ -1713,11 +1766,9 @@ def add_remote_link( Warning, ) - data = {} - if isinstance(destination, Issue): - + data: Dict[str, Any] = {} + if isinstance(destination, Issue) and destination.raw: data["object"] = {"title": str(destination), "url": destination.permalink()} - for x in applicationlinks: if x["application"]["displayUrl"] == destination._options["server"]: data["globalId"] = "appId=%s&issueId=%s" % ( @@ -1743,17 +1794,18 @@ def add_remote_link( data["relationship"] = relationship # check if the link comes from one of the configured application links - for x in applicationlinks: - if x["application"]["displayUrl"] == self.server_url: - data["globalId"] = "appId=%s&issueId=%s" % ( - x["application"]["id"], - destination.raw["id"], - ) - data["application"] = { - "name": x["application"]["name"], - "type": "com.atlassian.jira", - } - break + if isinstance(destination, Issue) and destination.raw: + for x in applicationlinks: + if x["application"]["displayUrl"] == self.server_url: + data["globalId"] = "appId=%s&issueId=%s" % ( + x["application"]["id"], + destination.raw["id"], # .raw only present on Issue + ) + data["application"] = { + "name": x["application"]["name"], + "type": "com.atlassian.jira", + } + break url = self._get_url("issue/" + str(issue) + "/remotelink") r = self._session.post(url, data=json.dumps(data)) @@ -1761,20 +1813,24 @@ def add_remote_link( remote_link = RemoteLink(self._options, self._session, raw=json_loads(r)) return remote_link - def add_simple_link(self, issue, object): + def add_simple_link(self, issue: str, object: Dict[str, Any]): """Add a simple remote link from an issue to web resource. This avoids the admin access problems from add_remote_link by just - using a simple object and presuming all fields are correct and not - requiring more complex ``application`` data. + using a simple object and presuming all fields are correct and not + requiring more complex ``application`` data. ``object`` should be a dict containing at least ``url`` to the - linked external URL and ``title`` to display for the link inside Jira. + linked external URL and ``title`` to display for the link inside Jira. For definitions of the allowable fields for ``object`` , see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links. - :param issue: the issue to add the remote link to - :param object: the dictionary used to create remotelink data + Args: + issue (str): the issue to add the remote link to + object (Dict[str,Any]): the dictionary used to create remotelink data + + Returns: + RemoteLint """ data = {"object": object} url = self._get_url("issue/" + str(issue) + "/remotelink") @@ -1785,12 +1841,16 @@ def add_simple_link(self, issue, object): # non-resource @translate_resource_args - def transitions(self, issue, id=None, expand=None): + def transitions(self, issue: str, id: Optional[str] = None, expand=None): """Get a list of the transitions available on the specified issue to the current user. - :param issue: ID or key of the issue to get the transitions from - :param id: if present, get only the transition matching this ID - :param expand: extra information to fetch inside each transition + Args: + issue (str): ID or key of the issue to get the transitions from + id (Optional[str]): if present, get only the transition matching this ID + expand (Optional): extra information to fetch inside each transition + + Returns: + Any: json of response """ params = {} if id is not None: @@ -1801,16 +1861,19 @@ def transitions(self, issue, id=None, expand=None): "transitions" ] - def find_transitionid_by_name(self, issue, transition_name): + def find_transitionid_by_name( + self, issue: str, transition_name: str + ) -> Optional[int]: """Get a transitionid available on the specified issue to the current user. Look at https://developer.atlassian.com/static/rest/jira/6.1.html#d2e1074 for json reference - :param issue: ID or key of the issue to get the transitions from - :param trans_name: iname of transition we are looking for + Args: + issue (str): ID or key of the issue to get the transitions from + trans_name (str): iname of transition we are looking for """ transitions_json = self.transitions(issue) - id = None + id: Optional[int] = None for transition in transitions_json: if transition["name"].lower() == transition_name.lower(): @@ -1820,7 +1883,13 @@ def find_transitionid_by_name(self, issue, transition_name): @translate_resource_args def transition_issue( - self, issue, transition, fields=None, comment=None, worklog=None, **fieldargs + self, + issue: str, + transition: str, + fields: Optional[Dict[str, Any]] = None, + comment: Optional[str] = None, + worklog: Optional[str] = None, + **fieldargs ): """Perform a transition on an issue. @@ -1828,14 +1897,15 @@ def transition_issue( is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments will be ignored. Field values will be set on the issue as part of the transition process. - :param issue: ID or key of the issue to perform the transition on - :param transition: ID or name of the transition to perform - :param comment: *Optional* String to add as comment to the issue when - performing the transition. - :param fields: a dict containing field names and the values to use. - If present, all other keyword arguments will be ignored + Args: + issue (str): ID or key of the issue to perform the transition on + transition (str): ID or name of the transition to perform + fields (Optional[Dict[str,Any]]): a dict containing field names and the values to use. + comment (Optional[str]): String to add as comment to the issue when performing the transition. + workload (Optional[str]): String to add as time spent on the issue when performing the transition. + **fieldargs: If present, all other keyword arguments will be ignored """ - transitionId = None + transitionId: Optional[int] = None try: transitionId = int(transition) @@ -1845,7 +1915,7 @@ def transition_issue( if transitionId is None: raise JIRAError("Invalid transition name. %s" % transition) - data = {"transition": {"id": transitionId}} + data: Dict[str, Any] = {"transition": {"id": transitionId}} if comment: data["update"] = {"comment": [{"add": {"body": comment}}]} if worklog: @@ -1863,64 +1933,76 @@ def transition_issue( try: r_json = json_loads(r) except ValueError as e: - logging.error("%s\n%s" % (e, r.text)) + self.log.error("%s\n%s" % (e, r.text)) raise e return r_json @translate_resource_args - def votes(self, issue): + def votes(self, issue: str) -> Votes: """Get a votes Resource from the server. - :param issue: ID or key of the issue to get the votes for - :rtype: Votes + Args: + issue (str): ID or key of the issue to get the votes for + Returns: + Votes """ return self._find_for_resource(Votes, issue) @translate_resource_args - def add_vote(self, issue): + def add_vote(self, issue: str) -> Response: """Register a vote for the current authenticated user on an issue. - :param issue: ID or key of the issue to vote on - :rtype: Response + Args: + issue (str): ID or key of the issue to vote on + + Returns: + Response """ url = self._get_url("issue/" + str(issue) + "/votes") return self._session.post(url) @translate_resource_args - def remove_vote(self, issue): + def remove_vote(self, issue: str): """Remove the current authenticated user's vote from an issue. - :param issue: ID or key of the issue to remove vote on + Args: + issue (str): ID or key of the issue to remove vote on """ url = self._get_url("issue/" + str(issue) + "/votes") self._session.delete(url) @translate_resource_args - def watchers(self, issue): + def watchers(self, issue: str) -> Watchers: """Get a watchers Resource from the server for an issue. - :param issue: ID or key of the issue to get the watchers for - :rtype: Watchers + Args: + issue (str): ID or key of the issue to get the watchers for + Returns: + Watchers """ return self._find_for_resource(Watchers, issue) @translate_resource_args - def add_watcher(self, issue, watcher): + def add_watcher(self, issue: str, watcher: str) -> Response: """Add a user to an issue's watchers list. - :param issue: ID or key of the issue affected - :param watcher: key of the user to add to the watchers list + Args: + issue (str): ID or key of the issue affected + watcher (str): key of the user to add to the watchers list """ url = self._get_url("issue/" + str(issue) + "/watchers") - self._session.post(url, data=json.dumps(watcher)) + return self._session.post(url, data=json.dumps(watcher)) @translate_resource_args - def remove_watcher(self, issue, watcher): + def remove_watcher(self, issue: str, watcher: str) -> Response: """Remove a user from an issue's watch list. - :param issue: ID or key of the issue affected - :param watcher: key of the user to remove from the watchers list - :rtype: Response + Args: + issue (str): ID or key of the issue affected + watcher (str): key of the user to remove from the watchers list + + Returns: + Response """ url = self._get_url("issue/" + str(issue) + "/watchers") # https://docs.atlassian.com/software/jira/docs/api/REST/8.13.6/#api/2/issue-removeWatcher @@ -1929,11 +2011,13 @@ def remove_watcher(self, issue, watcher): return result @translate_resource_args - def worklogs(self, issue): + def worklogs(self, issue: str) -> List[Worklog]: """Get a list of worklog Resources from the server for an issue. - :param issue: ID or key of the issue to get worklogs from - :rtype: List[Worklog] + Args: + issue (str): ID or key of the issue to get worklogs from + Returns: + List[Worklog] """ r_json = self._get_json("issue/" + str(issue) + "/worklog") worklogs = [ @@ -1943,12 +2027,14 @@ def worklogs(self, issue): return worklogs @translate_resource_args - def worklog(self, issue, id): + def worklog(self, issue: str, id: str) -> Worklog: """Get a specific worklog Resource from the server. - :param issue: ID or key of the issue to get the worklog from - :param id: ID of the worklog to get - :rtype: Worklog + Args: + issue (str): ID or key of the issue to get the worklog from + id (str): ID of the worklog to get + Returns: + Worklog """ return self._find_for_resource(Worklog, (issue, id)) @@ -1956,26 +2042,30 @@ def worklog(self, issue, id): def add_worklog( self, issue, - timeSpent=None, - timeSpentSeconds=None, - adjustEstimate=None, - newEstimate=None, - reduceBy=None, - comment=None, - started=None, - user=None, - ): + timeSpent: (Optional[str]) = None, + timeSpentSeconds: (Optional[str]) = None, + adjustEstimate: (Optional[str]) = None, + newEstimate: (Optional[str]) = None, + reduceBy: (Optional[str]) = None, + comment: (Optional[str]) = None, + started: (Optional[datetime.datetime]) = None, + user: (Optional[str]) = None, + ) -> Worklog: """Add a new worklog entry on an issue and return a Resource for it. - :param issue: the issue to add the worklog to - :param timeSpent: a worklog entry with this amount of time spent, e.g. "2d" - :param adjustEstimate: (optional) allows the user to provide specific instructions to update the remaining - time estimate of the issue. The value can either be ``new``, ``leave``, ``manual`` or ``auto`` (default). - :param newEstimate: the new value for the remaining estimate field. e.g. "2d" - :param reduceBy: the amount to reduce the remaining estimate by e.g. "2d" - :param started: Moment when the work is logged, if not specified will default to now - :param comment: optional worklog comment - :rtype: Worklog + Args: + issue (str): the issue to add the worklog to + timeSpent (Optional[str]): a worklog entry with this amount of time spent, e.g. "2d" + timeSpentSeconds (Optional[str]): a worklog entry with this amount of time spent in seconds + adjustEstimate (Optional[str]): allows the user to provide specific instructions to update + the remaining time estimate of the issue. The value can either be ``new``, ``leave``, ``manual`` or ``auto`` (default). + newEstimate (Optional[str]): the new value for the remaining estimate field. e.g. "2d" + reduceBy (Optional[str]): the amount to reduce the remaining estimate by e.g. "2d" + comment (Optional[str]): optional worklog comment + started (Optional[datetime.datetime]): Moment when the work is logged, if not specified will default to now + user (Optional[str]): the user ID or name to use for this worklog + Returns: + Worklog """ params = {} if adjustEstimate is not None: @@ -1985,7 +2075,7 @@ def add_worklog( if reduceBy is not None: params["reduceBy"] = reduceBy - data = {} + data: Dict[str, Any] = {} if timeSpent is not None: data["timeSpent"] = timeSpent if timeSpentSeconds is not None: @@ -2020,21 +2110,29 @@ def add_worklog( # Issue links @translate_resource_args - def create_issue_link(self, type, inwardIssue, outwardIssue, comment=None): + def create_issue_link( + self, + type: Union[str, IssueLinkType], + inwardIssue: str, + outwardIssue: str, + comment: Optional[Dict[str, Any]] = None, + ) -> Response: """Create a link between two issues. - :param type: the type of link to create - :param inwardIssue: the issue to link from - :param outwardIssue: the issue to link to - :param comment: a comment to add to the issues with the link. Should be - a dict containing ``body`` and ``visibility`` fields: ``body`` being - the text of the comment and ``visibility`` being a dict containing - two entries: ``type`` and ``value``. ``type`` is ``role`` (or - ``group`` if the Jira server has configured comment visibility for - groups) and ``value`` is the name of the role (or group) to which - viewing of this comment will be restricted. - :type comment: Optional[Dict[str, Any]] - :rtype: Response + Args: + type (Union[str,IssueLinkType]): the type of link to create + inwardIssue: the issue to link from + outwardIssue: the issue to link to + comment (Optional[Dict[str, Any]]): a comment to add to the issues with the link. + Should be a dict containing ``body`` and ``visibility`` fields: ``body`` being + the text of the comment and ``visibility`` being a dict containing + two entries: ``type`` and ``value``. ``type`` is ``role`` (or + ``group`` if the Jira server has configured comment visibility for + groups) and ``value`` is the name of the role (or group) to which + viewing of this comment will be restricted. + + Returns: + Response """ # let's see if we have the right issue link 'type' and fix it if needed issue_link_types = self.issue_link_types() @@ -2060,27 +2158,30 @@ def create_issue_link(self, type, inwardIssue, outwardIssue, comment=None): url = self._get_url("issueLink") return self._session.post(url, data=json.dumps(data)) - def delete_issue_link(self, id): + def delete_issue_link(self, id: str): """Delete a link between two issues. - :param id: ID of the issue link to delete + Args: + id (str): ID of the issue link to delete """ url = self._get_url("issueLink") + "/" + id return self._session.delete(url) - def issue_link(self, id): + def issue_link(self, id: str): """Get an issue link Resource from the server. - :param id: ID of the issue link to get + Args: + id (str): ID of the issue link to get """ return self._find_for_resource(IssueLink, id) # Issue link types - def issue_link_types(self, force=False): + def issue_link_types(self, force: bool = False) -> List[IssueLinkType]: """Get a list of issue link type Resources from the server. - :rtype: List[IssueLinkType] + Returns: + List[IssueLinkType] """ if not hasattr(self, "self._cached_issue_link_types") or force: r_json = self._get_json("issueLinkType") @@ -2090,22 +2191,25 @@ def issue_link_types(self, force=False): ] return self._cached_issue_link_types - def issue_link_type(self, id): + def issue_link_type(self, id: str) -> IssueLinkType: """Get an issue link type Resource from the server. - :param id: ID of the issue link type to get - :type id: str - :rtype: IssueLinkType + Args: + id (str): ID of the issue link type to get + + Returns: + IssueLinkType """ return self._find_for_resource(IssueLinkType, id) # Issue types - def issue_types(self): + def issue_types(self) -> List[IssueType]: """Get a list of issue type Resources from the server. - :rtype: List[IssueType] + Returns: + List[IssueType] """ r_json = self._get_json("issuetype") @@ -2115,19 +2219,24 @@ def issue_types(self): ] return issue_types - def issue_type(self, id): + def issue_type(self, id: str) -> IssueType: """Get an issue type Resource from the server. - :param id: ID of the issue type to get - :rtype: IssueType + Args: + id (str): ID of the issue type to get + + Returns: + IssueType """ return self._find_for_resource(IssueType, id) - def issue_type_by_name(self, name): + def issue_type_by_name(self, name: str) -> IssueType: """ - :param name: Name of the issue type - :type name: str - :rtype: IssueType + Args: + name (str): Name of the issue type + + Returns: + IssueType """ issue_types = self.issue_types() try: @@ -2136,11 +2245,14 @@ def issue_type_by_name(self, name): raise KeyError("Issue type '%s' is unknown." % name) return issue_type - def request_types(self, service_desk): + def request_types(self, service_desk: ServiceDesk) -> List[RequestType]: """Returns request types supported by a service desk instance. - :param service_desk: The service desk instance. - :type service_desk: ServiceDesk - :rtype: List[RequestType] + + Args: + service_desk (ServiceDesk): The service desk instance. + + Returns: + List[RequestType] """ if hasattr(service_desk, "id"): service_desk = service_desk.id @@ -2156,7 +2268,7 @@ def request_types(self, service_desk): ] return request_types - def request_type_by_name(self, service_desk, name): + def request_type_by_name(self, service_desk: ServiceDesk, name: str): request_types = self.request_types(service_desk) try: request_type = [rt for rt in request_types if rt.name == name][0] @@ -2168,19 +2280,22 @@ def request_type_by_name(self, service_desk, name): # non-resource def my_permissions( - self, projectKey=None, projectId=None, issueKey=None, issueId=None - ): + self, + projectKey: Optional[str] = None, + projectId: Optional[str] = None, + issueKey: Optional[str] = None, + issueId: Optional[str] = None, + ) -> Dict[str, Dict[str, Dict[str, str]]]: """Get a dict of all available permissions on the server. - :param projectKey: limit returned permissions to the specified project - :type projectKey: Optional[str] - :param projectId: limit returned permissions to the specified project - :type projectId: Optional[str] - :param issueKey: limit returned permissions to the specified issue - :type issueKey: Optional[str] - :param issueId: limit returned permissions to the specified issue - :type issueId: Optional[str] - :rtype: Dict[str, Dict[str, Dict[str, str]]] + Args: + projectKey (Optional[str]): limit returned permissions to the specified project + projectId (Optional[str]): limit returned permissions to the specified project + issueKey (Optional[str]): limit returned permissions to the specified issue + issueId (Optional[str]): limit returned permissions to the specified issue + + Returns: + Dict[str, Dict[str, Dict[str, str]]] """ params = {} if projectKey is not None: @@ -2198,7 +2313,8 @@ def my_permissions( def priorities(self): """Get a list of priority Resources from the server. - :rtype: List[Priority] + Returns: + List[Priority] """ r_json = self._get_json("priority") @@ -2208,22 +2324,25 @@ def priorities(self): ] return priorities - def priority(self, id): + def priority(self, id: str) -> Priority: """Get a priority Resource from the server. - :param id: ID of the priority to get - :type id: str - :rtype: Priority + Args: + id (str): ID of the priority to get + + Returns: + Priority """ return self._find_for_resource(Priority, id) # Projects - def projects(self): + def projects(self) -> List[Project]: """Get a list of project Resources from the server visible to the current authenticated user. - :rtype: List[Project] + Returns: + List[Project] """ r_json = self._get_json("project") @@ -2233,52 +2352,61 @@ def projects(self): ] return projects - def project(self, id): + def project(self, id: str) -> Project: """Get a project Resource from the server. - :param id: ID or key of the project to get - :rtype: Project + Args: + id (str): ID or key of the project to get + + Returns: + Project """ return self._find_for_resource(Project, id) # non-resource @translate_resource_args - def project_avatars(self, project): + def project_avatars(self, project: str): """Get a dict of all avatars for a project visible to the current authenticated user. - :param project: ID or key of the project to get avatars for + Args: + project (str): ID or key of the project to get avatars for """ return self._get_json("project/" + project + "/avatars") @translate_resource_args def create_temp_project_avatar( - self, project, filename, size, avatar_img, contentType=None, auto_confirm=False + self, + project: str, + filename: str, + size: int, + avatar_img: bytes, + contentType: str = None, + auto_confirm: bool = False, ): """Register an image file as a project avatar. - The avatar created is temporary and must be confirmed before it can - be used. + The avatar created is temporary and must be confirmed before it can be used. Avatar images are specified by a filename, size, and file object. By default, the client will attempt to - autodetect the picture's content type: this mechanism relies on libmagic and will not work out of the box - on Windows systems (see https://filemagic.readthedocs.io/en/latest/guide.html for details on how to install - support). The ``contentType`` argument can be used to explicitly set the value (note that Jira will reject any - type other than the well-known ones for images, e.g. ``image/jpg``, ``image/png``, etc.) + autodetect the picture's content type: this mechanism relies on libmagic and will not work out of the box + on Windows systems (see https://filemagic.readthedocs.io/en/latest/guide.html for details on how to install + support). The ``contentType`` argument can be used to explicitly set the value (note that Jira will reject any + type other than the well-known ones for images, e.g. ``image/jpg``, ``image/png``, etc.) This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This - dict should be saved and passed to :py:meth:`confirm_project_avatar` to finish the avatar creation process. If - you want to cut out the middleman and confirm the avatar with Jira's default cropping, pass the 'auto_confirm' - argument with a truthy value and :py:meth:`confirm_project_avatar` will be called for you before this method - returns. - - :param project: ID or key of the project to create the avatar in - :param filename: name of the avatar file - :param size: size of the avatar file - :param avatar_img: file-like object holding the avatar - :param contentType: explicit specification for the avatar image's content-type - :param auto_confirm: whether to automatically confirm the temporary avatar by calling - :py:meth:`confirm_project_avatar` with the return value of this method. (Default: False) - :type auto_confirm: bool + dict should be saved and passed to :py:meth:`confirm_project_avatar` to finish the avatar creation process. If + you want to cut out the middleman and confirm the avatar with Jira's default cropping, pass the 'auto_confirm' + argument with a truthy value and :py:meth:`confirm_project_avatar` will be called for you before this method + returns. + + Args: + project (str): ID or key of the project to create the avatar in + filename (str): name of the avatar file + size (int): size of the avatar file + avatar_img (bytes): file-like object holding the avatar + contentType (str): explicit specification for the avatar image's content-type + auto_confirm (bool): whether to automatically confirm the temporary avatar by calling + :py:meth:`confirm_project_avatar` with the return value of this method. (Default: False) """ size_from_file = os.path.getsize(filename) if size != size_from_file: @@ -2286,7 +2414,7 @@ def create_temp_project_avatar( params = {"filename": filename, "size": size} - headers = {"X-Atlassian-Token": "no-check"} + headers: Dict[str, Any] = {"X-Atlassian-Token": "no-check"} if contentType is not None: headers["content-type"] = contentType else: @@ -2296,14 +2424,14 @@ def create_temp_project_avatar( url = self._get_url("project/" + project + "/avatar/temporary") r = self._session.post(url, params=params, headers=headers, data=avatar_img) - cropping_properties = json_loads(r) + cropping_properties: Dict[str, Any] = json_loads(r) if auto_confirm: return self.confirm_project_avatar(project, cropping_properties) else: return cropping_properties @translate_resource_args - def confirm_project_avatar(self, project, cropping_properties): + def confirm_project_avatar(self, project: str, cropping_properties: Dict[str, Any]): """Confirm the temporary avatar image previously uploaded with the specified cropping. After a successful registry with :py:meth:`create_temp_project_avatar`, use this method to confirm the avatar @@ -2311,8 +2439,9 @@ def confirm_project_avatar(self, project, cropping_properties): ``cropping_properties``: the return value of :py:meth:`create_temp_project_avatar` should be used for this argument. - :param project: ID or key of the project to confirm the avatar in - :param cropping_properties: a dict of cropping properties from :py:meth:`create_temp_project_avatar` + Args: + project (str): ID or key of the project to confirm the avatar in + cropping_properties (Dict[str,Any]): a dict of cropping properties from :py:meth:`create_temp_project_avatar` """ data = cropping_properties url = self._get_url("project/" + project + "/avatar") @@ -2321,31 +2450,35 @@ def confirm_project_avatar(self, project, cropping_properties): return json_loads(r) @translate_resource_args - def set_project_avatar(self, project, avatar): + def set_project_avatar(self, project: str, avatar: str): """Set a project's avatar. - :param project: ID or key of the project to set the avatar on - :param avatar: ID of the avatar to set + Args: + project (str): ID or key of the project to set the avatar on + avatar (str): ID of the avatar to set """ self._set_avatar(None, self._get_url("project/" + project + "/avatar"), avatar) @translate_resource_args - def delete_project_avatar(self, project, avatar): + def delete_project_avatar(self, project: str, avatar: str) -> Response: """Delete a project's avatar. - :param project: ID or key of the project to delete the avatar from - :param avatar: ID of the avatar to delete + Args: + project (str): ID or key of the project to delete the avatar from + avatar (str): ID of the avatar to delete """ url = self._get_url("project/" + project + "/avatar/" + avatar) return self._session.delete(url) @translate_resource_args - def project_components(self, project): + def project_components(self, project: str) -> List[Component]: """Get a list of component Resources present on a project. - :param project: ID or key of the project to get components from - :type project: str - :rtype: List[Component] + Args: + project (str): ID or key of the project to get components from + + Returns: + List[Component] """ r_json = self._get_json("project/" + project + "/components") components = [ @@ -2355,12 +2488,14 @@ def project_components(self, project): return components @translate_resource_args - def project_versions(self, project): + def project_versions(self, project: str) -> List[Version]: """Get a list of version Resources present on a project. - :param project: ID or key of the project to get versions from - :type project: str - :rtype: List[Version] + Args: + project (str): ID or key of the project to get versions from + + Returns: + List[Version] """ r_json = self._get_json("project/" + project + "/versions") versions = [ @@ -2370,31 +2505,35 @@ def project_versions(self, project): return versions @translate_resource_args - def get_project_version_by_name(self, project, version_name): + def get_project_version_by_name( + self, project: str, version_name: str + ) -> Optional[Version]: """Get a version Resource by its name present on a project. - :param project: ID or key of the project to get versions from - :type project: str - :param version_name: name of the version to search for - :type version_name: str - :rtype: Optional[Version] + Args: + project (str): ID or key of the project to get versions from + version_name (str): name of the version to search for + + Returns: + Optional[Version] """ - versions = self.project_versions(project) + versions: List[Version] = self.project_versions(project) for version in versions: if version.name == version_name: return version + return None @translate_resource_args - def rename_version(self, project, old_name, new_name): + def rename_version(self, project: str, old_name: str, new_name: str) -> None: """Rename a version Resource on a project. - :param project: ID or key of the project to get versions from - :type project: str - :param old_name: old name of the version to rename - :type old_name: str - :param new_name: new name of the version to rename - :type new_name: str - :rtype: None + Args: + project (str): ID or key of the project to get versions from + old_name (str): old name of the version to rename + new_name (str): new name of the version to rename + + Returns: + None """ version = self.get_project_version_by_name(project, old_name) if version: @@ -2402,17 +2541,18 @@ def rename_version(self, project, old_name, new_name): # non-resource @translate_resource_args - def project_roles(self, project): + def project_roles(self, project: str) -> Dict[str, Dict[str, str]]: """Get a dict of role names to resource locations for a project. - :param project: ID or key of the project to get roles from + Args: + project (str): ID or key of the project to get roles from """ path = "project/" + project + "/role" - _rolesdict = self._get_json(path) - rolesdict = {} + _rolesdict: Dict[str, str] = self._get_json(path) + rolesdict: Dict[str, Dict[str, str]] = {} for k, v in _rolesdict.items(): - tmp = {} + tmp: Dict[str, str] = {} tmp["id"] = v.split("/")[-1] tmp["url"] = v rolesdict[k] = tmp @@ -2420,11 +2560,12 @@ def project_roles(self, project): # TODO(ssbarnea): return a list of Roles() @translate_resource_args - def project_role(self, project, id): + def project_role(self, project: str, id: str) -> Role: """Get a role Resource. - :param project: ID or key of the project to get the role from - :param id: ID of the role to get + Args: + project (str): ID or key of the project to get the role from + id (str): ID of the role to get """ if isinstance(id, Number): id = "%s" % id @@ -2432,10 +2573,11 @@ def project_role(self, project, id): # Resolutions - def resolutions(self): + def resolutions(self) -> List[Resolution]: """Get a list of resolution Resources from the server. - :rtype: List[Resolution] + Returns: + List[Resolution] """ r_json = self._get_json("resolution") @@ -2445,12 +2587,14 @@ def resolutions(self): ] return resolutions - def resolution(self, id): + def resolution(self, id: str) -> Resolution: """Get a resolution Resource from the server. - :param id: ID of the resolution to get - :type id: str - :rtype: Resolution + Args: + id (str): ID of the resolution to get + + Returns: + Resolution """ return self._find_for_resource(Resolution, id) @@ -2458,36 +2602,31 @@ def resolution(self, id): def search_issues( self, - jql_str, - startAt=0, - maxResults=50, - validate_query=True, - fields=None, - expand=None, - json_result=None, - ): + jql_str: str, + startAt: int = 0, + maxResults: int = 50, + validate_query: bool = True, + fields: Optional[Union[str, List[str]]] = None, + expand: Optional[str] = None, + json_result: bool = False, + ) -> Union[List[Dict[str, Any]], ResultList[Issue]]: """Get a :class:`~jira.client.ResultList` of issue Resources matching a JQL search string. - :param jql_str: The JQL search string. - :type jql_str: str - :param startAt: Index of the first issue to return. (Default: 0) - :type startAt: int - :param maxResults: Maximum number of issues to return. Total number of results - is available in the ``total`` attribute of the returned :class:`~jira.client.ResultList`. - If maxResults evaluates as False, it will try to get all issues in batches. (Default: 50) - :type maxResults: int - :param validate_query: Whether or not the query should be validated. (Default: True) - :type validate_query: bool - :param fields: comma-separated string or list of issue fields to include in the results. - Default is to include all fields. - :type fields: Optional[str or list] - :param expand: extra information to fetch inside each resource - :type expand: Optional[str] - :param json_result: JSON response will be returned when this parameter is set to True. - Otherwise, :class:`~jira.client.ResultList` will be returned. - :type json_result: bool - - :rtype: dict or :class:`~jira.client.ResultList` + Args: + jql_str (str): The JQL search string. + 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:`~jira.client.ResultList`. + If maxResults evaluates as False, it will try to get all issues in batches. (Default: 50) + validate_query (bool): Whether or not the query should be validated. (Default: True) + fields (Optional[Union[str, List[str]]]): comma-separated string or list of issue fields to include in the results. + Default is to include all fields. + expand (Optional[str]): extra information to fetch inside each resource + json_result (bool): JSON response will be returned when this parameter is set to True. + Otherwise, :class:`~jira.client.ResultList` will be returned. + + Returns: + Union[Dict,ResultList]: Dict if ``json_result=True`` """ if isinstance(fields, str): @@ -2518,56 +2657,64 @@ def search_issues( "All issues cannot be fetched at once, when json_result parameter is set", Warning, ) - return self._get_json("search", params=search_params) + r_json: List[Dict[str, Any]] = self._get_json( + "search", params=search_params + ) + return r_json issues = self._fetch_pages( Issue, "issues", "search", startAt, maxResults, search_params ) if untranslate: - for i in issues: + iss: Issue + for iss in issues: for k, v in untranslate.items(): - if k in i.raw.get("fields", {}): - i.raw["fields"][v] = i.raw["fields"][k] + if iss.raw: + if k in iss.raw.get("fields", {}): + iss.raw["fields"][v] = iss.raw["fields"][k] return issues # Security levels - def security_level(self, id): + def security_level(self, id: str) -> SecurityLevel: """Get a security level Resource. - :param id: ID of the security level to get + Args: + id (str): ID of the security level to get """ return self._find_for_resource(SecurityLevel, id) # Server info # non-resource - def server_info(self): + def server_info(self) -> Dict[str, Any]: """Get a dict of server information for this Jira instance. - :rtype: Dict[str, Any] + + Returns: + Dict[str, Any] """ retry = 0 j = self._get_json("serverInfo") while not j and retry < 3: - logging.warning( + self.log.warning( "Bug https://jira.atlassian.com/browse/JRA-59676 trying again..." ) retry += 1 j = self._get_json("serverInfo") return j - def myself(self): + def myself(self) -> Dict[str, Any]: """Get a dict of server information for this Jira instance.""" return self._get_json("myself") # Status - def statuses(self): + def statuses(self) -> List[Status]: """Get a list of status Resources from the server. - :rtype: List[Status] - + Returns: + List[Status] """ r_json = self._get_json("status") statuses = [ @@ -2576,22 +2723,24 @@ def statuses(self): ] return statuses - def status(self, id): + def status(self, id: str) -> Status: """Get a status Resource from the server. - :param id: ID of the status resource to get - :type id: str + Args: + id (str): ID of the status resource to get - :rtype: Status + Returns: + Status """ return self._find_for_resource(Status, id) # Category - def statuscategories(self): + def statuscategories(self) -> List[StatusCategory]: """Get a list of status category Resources from the server. - :rtype: List[StatusCategory] + Returns: + List[StatusCategory] """ r_json = self._get_json("statuscategory") statuscategories = [ @@ -2600,28 +2749,29 @@ def statuscategories(self): ] return statuscategories - def statuscategory(self, id): + def statuscategory(self, id: int) -> StatusCategory: """Get a status category Resource from the server. - :param id: ID of the status category resource to get - :type id: int + Args: + id (int): ID of the status category resource to get - :rtype: StatusCategory + Returns: + StatusCategory """ return self._find_for_resource(StatusCategory, id) # Users - def user(self, id, expand=None): + def user(self, id: str, expand: Optional[Any] = None) -> User: """Get a user Resource from the server. - :param id: ID of the user to get - :param id: str - :param expand: Extra information to fetch inside each resource - :type expand: Optional[Any] + Args: + id (str): ID of the user to get + expand (Optional[Any]): Extra information to fetch inside each resource - :rtype: User + Returns: + User """ user = User(self._options, self._session) params = {} @@ -2631,21 +2781,19 @@ def user(self, id, expand=None): return user def search_assignable_users_for_projects( - self, username, projectKeys, startAt=0, maxResults=50 - ): + self, username: str, projectKeys: str, startAt: int = 0, maxResults: int = 50 + ) -> ResultList: """Get a list of user Resources that match the search string and can be assigned issues for projects. - :param username: A string to match usernames against - :type username: str - :param projectKeys: Comma-separated list of project keys to check for issue assignment permissions - :type projectKeys: str - :param startAt: Index of the first user to return (Default: 0) - :type startAt: int - :param maxResults: Maximum number of users to return. - If maxResults evaluates as False, it will try to get all users in batches. (Default: 50) - :type maxResults: int + Args: + username (str): A string to match usernames against + projectKeys (str): Comma-separated list of project keys to check for issue assignment permissions + startAt (int): Index of the first user to return (Default: 0) + maxResults (int): Maximum number of users to return. + If maxResults evaluates as False, it will try to get all users in batches. (Default: 50) - :rtype: ResultList + Returns: + ResultList """ params = {"username": username, "projectKeys": projectKeys} @@ -2660,12 +2808,12 @@ def search_assignable_users_for_projects( def search_assignable_users_for_issues( self, - username, - project=None, - issueKey=None, - expand=None, - startAt=0, - maxResults=50, + username: str, + project: Optional[str] = None, + issueKey: Optional[str] = None, + expand: Optional[Any] = None, + startAt: int = 0, + maxResults: int = 50, ): """Get a list of user Resources that match the search string for assigning or creating issues. @@ -2673,21 +2821,19 @@ def search_assignable_users_for_issues( to an existing issue. When searching for eligible creators, specify a project. When searching for eligible assignees, specify an issue key. - :param username: A string to match usernames against - :type username: str - :param project: Filter returned users by permission in this project (expected if a result will be used to - create an issue) - :type project: Optional[str] - :param issueKey: Filter returned users by this issue (expected if a result will be used to edit this issue) - :type issueKey: Optional[str] - :param expand: Extra information to fetch inside each resource - :type expand: Optional[Any] - :param startAt: Index of the first user to return (Default: 0) - :type startAt: int - :param maxResults: maximum number of users to return. - If maxResults evaluates as False, it will try to get all items in batches. (Default: 50) - - :rtype: ResultList + Args: + username (str): A string to match usernames against + project (Optional[str]): Filter returned users by permission in this project + (expected if a result will be used to create an issue) + issueKey (Optional[str]): Filter returned users by this issue + (expected if a result will be used to edit this issue) + expand (Optional[Any]): Extra information to fetch inside each resource + startAt (int): Index of the first user to return (Default: 0) + maxResults (int): maximum number of users to return. + If maxResults evaluates as False, it will try to get all items in batches. (Default: 50) + + Returns: + ResultList """ params = {"username": username} if project is not None: @@ -2701,15 +2847,22 @@ def search_assignable_users_for_issues( ) # non-resource - def user_avatars(self, username): + def user_avatars(self, username: str) -> Dict[str, Any]: """Get a dict of avatars for the specified user. - :param username: the username to get avatars for + Args: + username (str): the username to get avatars for """ return self._get_json("user/avatars", params={"username": username}) def create_temp_user_avatar( - self, user, filename, size, avatar_img, contentType=None, auto_confirm=False + self, + user: str, + filename: str, + size: int, + avatar_img: bytes, + contentType: Any = None, + auto_confirm: bool = False, ): """Register an image file as a user avatar. @@ -2728,21 +2881,15 @@ def create_temp_user_avatar( argument with a truthy value and :py:meth:`confirm_user_avatar` will be called for you before this method returns. - :param user: User to register the avatar for - :type user: str - :param filename: name of the avatar file - :type filename: str - :param size: size of the avatar file - :type size: int - :param avatar_img: file-like object containing the avatar - :type avatar_img: bytes - :param contentType: explicit specification for the avatar image's content-type - :type contentType: Optional[Any] - :param auto_confirm: whether to automatically confirm the temporary avatar by calling - :py:meth:`confirm_user_avatar` with the return value of this method. (Default: False) - :type auto_confirm: bool - - :rtype: NoReturn + Args: + user (str): User to register the avatar for + filename (str): name of the avatar file + size (int): size of the avatar file + avatar_img (bytes): file-like object containing the avatar + contentType (Optional[Any]): explicit specification for the avatar image's content-type + auto_confirm (bool): whether to automatically confirm the temporary avatar by calling + :py:meth:`confirm_user_avatar` with the return value of this method. (Default: False) + """ size_from_file = os.path.getsize(filename) if size != size_from_file: @@ -2753,6 +2900,7 @@ def create_temp_user_avatar( params = {"username": user, "filename": filename, "size": size} + headers: Dict[str, Any] headers = {"X-Atlassian-Token": "no-check"} if contentType is not None: headers["content-type"] = contentType @@ -2763,13 +2911,13 @@ def create_temp_user_avatar( url = self._get_url("user/avatar/temporary") r = self._session.post(url, params=params, headers=headers, data=avatar_img) - cropping_properties = json_loads(r) + cropping_properties: Dict[str, Any] = json_loads(r) if auto_confirm: return self.confirm_user_avatar(user, cropping_properties) else: return cropping_properties - def confirm_user_avatar(self, user, cropping_properties): + def confirm_user_avatar(self, user: str, cropping_properties: Dict[str, Any]): """Confirm the temporary avatar image previously uploaded with the specified cropping. After a successful registry with :py:meth:`create_temp_user_avatar`, use this method to confirm the avatar for @@ -2777,10 +2925,9 @@ def confirm_user_avatar(self, user, cropping_properties): ``cropping_properties``: the return value of :py:meth:`create_temp_user_avatar` should be used for this argument. - :param user: the user to confirm the avatar for - :type user: str - :param cropping_properties: a dict of cropping properties from :py:meth:`create_temp_user_avatar` - :type cropping_properties: Dict[str,Any] + Args: + user (str): the user to confirm the avatar for + cropping_properties (Dict[str,Any]): a dict of cropping properties from :py:meth:`create_temp_user_avatar` """ data = cropping_properties url = self._get_url("user/avatar") @@ -2788,43 +2935,48 @@ def confirm_user_avatar(self, user, cropping_properties): return json_loads(r) - def set_user_avatar(self, username, avatar): + def set_user_avatar(self, username: str, avatar: str) -> Response: """Set a user's avatar. - :param username: the user to set the avatar for - :param avatar: ID of the avatar to set + Args: + username (str): the user to set the avatar for + avatar (str): ID of the avatar to set """ - self._set_avatar({"username": username}, self._get_url("user/avatar"), avatar) + return self._set_avatar( + {"username": username}, self._get_url("user/avatar"), avatar + ) - def delete_user_avatar(self, username, avatar): + def delete_user_avatar(self, username: str, avatar: str): """Delete a user's avatar. - :param username: the user to delete the avatar from - :param avatar: ID of the avatar to remove + Args: + username (str): the user to delete the avatar from + avatar (str): ID of the avatar to remove """ params = {"username": username} url = self._get_url("user/avatar/" + avatar) return self._session.delete(url, params=params) def search_users( - self, user, startAt=0, maxResults=50, includeActive=True, includeInactive=False - ): + self, + user: str, + startAt: int = 0, + maxResults: int = 50, + includeActive: bool = True, + includeInactive: bool = False, + ) -> ResultList[User]: """Get a list of user Resources that match the specified search string. - :param user: a string to match usernames, name or email against. - :type user: str - :param startAt: index of the first user to return. - :type startAt: int - :param maxResults: maximum number of users to return. - If maxResults evaluates as False, it will try to get all items in batches. - :type maxResults: int - :param includeActive: If true, then active users are included in the results. (Default: True) - :type includeActive: bool - :param includeInactive: If true, then inactive users are included in the results. (Default: False) - :type includeInactive: bool - + Args: + user (str): a string to match usernames, name or email against. + startAt (int): index of the first user to return. + maxResults (int): maximum number of users to return. + If maxResults evaluates as False, it will try to get all items in batches. + includeActive (bool): If true, then active users are included in the results. (Default: True) + includeInactive (bool): If true, then inactive users are included in the results. (Default: False) - :rtype: ResultList + Returns: + ResultList[User] """ params = { "username": user, @@ -2834,21 +2986,25 @@ def search_users( return self._fetch_pages(User, None, "user/search", startAt, maxResults, params) def search_allowed_users_for_issue( - self, user, issueKey=None, projectKey=None, startAt=0, maxResults=50 - ): + self, + user: str, + issueKey: str = None, + projectKey: str = None, + startAt: int = 0, + maxResults: int = 50, + ) -> ResultList: """Get a list of user Resources that match a username string and have browse permission for the issue or project. - :param user: a string to match usernames against. - :type user: str - :param issueKey: find users with browse permission for this issue. - :type issueKey: Optional[str] - :param projectKey: find users with browse permission for this project. - :type projectKey: Optional[str] - :param startAt: index of the first user to return. (Default: 0) - :type startAt: int - :param maxResults: maximum number of users to return. - If maxResults evaluates as False, it will try to get all items in batches. (Default: 50) - :type maxResults: int + Args: + user (str): a string to match usernames against. + issueKey (Optional[str]): find users with browse permission for this issue. + projectKey (Optional[str]): find users with browse permission for this project. + startAt (int): index of the first user to return. (Default: 0) + maxResults (int): maximum number of users to return. + If maxResults evaluates as False, it will try to get all items in batches. (Default: 50) + + Returns: + ResultList """ params = {"username": user} if issueKey is not None: @@ -2864,32 +3020,27 @@ def search_allowed_users_for_issue( @translate_resource_args def create_version( self, - name, - project, - description=None, - releaseDate=None, - startDate=None, - archived=False, - released=False, - ): + name: str, + project: str, + description: str = None, + releaseDate: Any = None, + startDate: Any = None, + archived: bool = False, + released: bool = False, + ) -> Version: """Create a version in a project and return a Resource for it. - :param name: name of the version to create - :type name: str - :param project: key of the project to create the version in - :type project: str - :param description: a description of the version - :type description: str - :param releaseDate: the release date assigned to the version - :type releaseDate: Optional[Any] - :param startDate: The start date for the version - :type startDate: Optional[Any] - :param archived: Denotes whether a version should be archived. (Default: False) - :type archived: bool - :param released: Denotes whether a version is released. (Default: False) - :type released: bool - - :rtype: Version + Args: + name (str): name of the version to create + project (str): key of the project to create the version in + description (str): a description of the version + releaseDate (Optional[Any]): the release date assigned to the version + startDate (Optional[Any]): The start date for the version + archived (bool): Denotes whether a version should be archived. (Default: False) + released (bool): Denotes whether a version is released. (Default: False) + + Returns: + Version """ data = { "name": name, @@ -2911,15 +3062,19 @@ def create_version( version = Version(self._options, self._session, raw=json_loads(r)) return version - def move_version(self, id, after=None, position=None): + def move_version(self, id: str, after: str = None, position: str = None) -> Version: """Move a version within a project's ordered version list and return a new version Resource for it. One, but not both, of ``after`` and ``position`` must be specified. - :param id: ID of the version to move - :param after: the self attribute of a version to place the specified version after (that is, higher in the list) - :param position: the absolute position to move this version to: must be one of ``First``, ``Last``, - ``Earlier``, or ``Later`` + Args: + id (str): ID of the version to move + after (str): the self attribute of a version to place the specified version after (that is, higher in the list) + position (Optional[str]): the absolute position to move this version to: + must be one of ``First``, ``Last``, ``Earlier``, or ``Later`` + + Returns: + Version """ data = {} if after is not None: @@ -2933,15 +3088,15 @@ def move_version(self, id, after=None, position=None): version = Version(self._options, self._session, raw=json_loads(r)) return version - def version(self, id, expand=None): + def version(self, id: str, expand: Any = None) -> Version: """Get a version Resource. - :param id: ID of the version to get - :type id: str - :param expand: extra information to fetch inside each resource - :type expand: Optional[Any] + Args: + id (str): ID of the version to get + expand (Optional[Any]): extra information to fetch inside each resource - :rtype: Version + Returns: + Version """ version = Version(self._options, self._session) params = {} @@ -2950,31 +3105,34 @@ def version(self, id, expand=None): version.find(id, params=params) return version - def version_count_related_issues(self, id): + def version_count_related_issues(self, id: str): """Get a dict of the counts of issues fixed and affected by a version. - :param id: the version to count issues for + Args: + id (str): the version to count issues for """ - r_json = self._get_json("version/" + id + "/relatedIssueCounts") + r_json: Dict[str, Any] = self._get_json("version/" + id + "/relatedIssueCounts") del r_json["self"] # this isn't really an addressable resource return r_json - def version_count_unresolved_issues(self, id): + def version_count_unresolved_issues(self, id: str): """Get the number of unresolved issues for a version. - :param id: ID of the version to count issues for + Args: + id (str): ID of the version to count issues for """ - return self._get_json("version/" + id + "/unresolvedIssueCount")[ - "issuesUnresolvedCount" - ] + r_json: Dict[str, Any] = self._get_json( + "version/" + id + "/unresolvedIssueCount" + ) + return r_json["issuesUnresolvedCount"] # Session authentication - def session(self): + def session(self) -> User: """Get a dict of the current authenticated user's session information. - :rtype: User - + Returns: + User """ url = "{server}{auth_url}".format(**self._options) r = self._session.get(url) @@ -2982,49 +3140,58 @@ def session(self): user = User(self._options, self._session, json_loads(r)) return user - def kill_session(self): + def kill_session(self) -> Response: """Destroy the session of the current authenticated user.""" url = self.server_url + "/rest/auth/latest/session" return self._session.delete(url) # Websudo - def kill_websudo(self): + def kill_websudo(self) -> Optional[Response]: """Destroy the user's current WebSudo session. Works only for non-cloud deployments, for others does nothing. - :rtype: Optional[Any] + Returns: + Optional[Response] """ if self.deploymentType != "Cloud": url = self.server_url + "/rest/auth/1/websudo" return self._session.delete(url) + return None # Utilities - def _create_http_basic_session(self, username, password, timeout=None): + def _create_http_basic_session( + self, + username: str, + password: str, + timeout: Optional[Union[Union[float, int], Tuple[float, float]]] = None, + ): """Creates a basic http session. - :param username: Username for the session - :type username: str - :param password: Password for the username - :type password: str - :param timeout: If set determines the timeout period for the Session. - :type timeout: Optional[int] + Args: + username (str): Username for the session + password (str): Password for the username + timeout (Optional[int]): If set determines the timeout period for the Session. - :rtype: NoReturn + Returns: + ResilientSession """ - verify = self._options["verify"] + verify = bool(self._options["verify"]) self._session = ResilientSession(timeout=timeout) self._session.verify = verify self._session.auth = (username, password) - self._session.cert = self._options["client_cert"] + client_cert: Tuple[str, str] = self._options["client_cert"] # to help mypy + self._session.cert = client_cert - def _create_oauth_session(self, oauth, timeout): - verify = self._options["verify"] + def _create_oauth_session( + self, oauth, timeout: Optional[Union[Union[float, int], Tuple[float, float]]] + ): + verify = bool(self._options["verify"]) from oauthlib.oauth1 import SIGNATURE_RSA from requests_oauthlib import OAuth1 - oauth = OAuth1( + oauth_instance = OAuth1( oauth["consumer_key"], rsa_key=oauth["key_cert"], signature_method=SIGNATURE_RSA, @@ -3033,10 +3200,14 @@ def _create_oauth_session(self, oauth, timeout): ) self._session = ResilientSession(timeout) self._session.verify = verify - self._session.auth = oauth + self._session.auth = oauth_instance - def _create_kerberos_session(self, timeout, kerberos_options=None): - verify = self._options["verify"] + def _create_kerberos_session( + self, + timeout: Optional[Union[Union[float, int], Tuple[float, float]]], + kerberos_options=None, + ): + verify = bool(self._options["verify"]) if kerberos_options is None: kerberos_options = {} @@ -3059,17 +3230,19 @@ def _create_kerberos_session(self, timeout, kerberos_options=None): ) @staticmethod - def _timestamp(dt=None): + def _timestamp(dt: datetime.timedelta = None): t = datetime.datetime.utcnow() if dt is not None: t += dt return calendar.timegm(t.timetuple()) - def _create_jwt_session(self, jwt, timeout): + def _create_jwt_session( + self, jwt, timeout: Optional[Union[Union[float, int], Tuple[float, float]]] + ): try: jwt_auth = JWTAuth(jwt["secret"], alg="HS256") except NameError as e: - logging.error("JWT authentication requires requests_jwt") + self.log.error("JWT authentication requires requests_jwt") raise e jwt_auth.set_header_format("JWT %s") @@ -3081,58 +3254,55 @@ def _create_jwt_session(self, jwt, timeout): for f in jwt["payload"].items(): jwt_auth.add_field(f[0], f[1]) self._session = ResilientSession(timeout=timeout) - self._session.verify = self._options["verify"] + self._session.verify = bool(self._options["verify"]) self._session.auth = jwt_auth def _set_avatar(self, params, url, avatar): data = {"id": avatar} return self._session.put(url, params=params, data=json.dumps(data)) - def _get_url(self, path, base=JIRA_BASE_URL): + 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. Using the API version specified during the __init__. - :param path: The subpath desired. - :type path: str - :param base: The base url which should be prepended to the path - :type base: Optional[str] - - :return Fully qualified URL - :rtype: str + 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}) return base.format(**options) - def _get_latest_url(self, path, base=JIRA_BASE_URL): + def _get_latest_url(self, path: str, base: str = JIRA_BASE_URL) -> str: """Returns the full url based on Jira base url and the path provided. Using the latest API endpoint. - :param path: The subpath desired. - :type path: str - :param base: The base url which should be prepended to the path - :type base: Optional[str] - - :return Fully qualified URL - :rtype: str + 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"}) return base.format(**options) - def _get_json(self, path, params=None, base=JIRA_BASE_URL): + def _get_json( + self, path: str, params: Dict[str, Any] = None, base: str = JIRA_BASE_URL + ): """Get the json for a given path and params. - :param path: The subpath required - :type path: str - :param params: Parameters to filter the json query. - :type params: Optional[Dict[str, Any]] - :param base: The Base Jira URL, defaults to the instance base. - :type base: Optional[str] + Args: + path (str): The subpath required + params (Optional[Dict[str, Any]]): Parameters to filter the json query. + base (Optional[str]): The Base Jira URL, defaults to the instance base. - :rtype: Union[Dict[str, Any], List[Dict[str, str]]] + Returns: + Union[Dict[str, Any], List[Dict[str, str]]] """ url = self._get_url(path, base) @@ -3140,18 +3310,34 @@ def _get_json(self, path, params=None, base=JIRA_BASE_URL): try: r_json = json_loads(r) except ValueError as e: - logging.error("%s\n%s" % (e, r.text)) + self.log.error("%s\n%s" % (e, r.text if r else r)) raise e return r_json - def _find_for_resource(self, resource_cls, ids, expand=None): + def _find_for_resource( + self, resource_cls: Any, ids: Union[Tuple[str, str], int, str], expand=None + ) -> Any: + """Uses the find method of the provided Resource class + + Args: + resource_cls (Any): Any instance of :py:class`Resource` + ids (Union[Tuple[str, str], int, str]): The arguments to the Resource's ``find()`` + expand ([type], optional): The value for the expand property in the Resource's + ``find()`` params. Defaults to None. + + Raises: + JIRAError: If the Resource cannot be found + + Returns: + Any: A class of the same type as ``resource_cls`` + """ resource = resource_cls(self._options, self._session) params = {} if expand is not None: params["expand"] = expand resource.find(id=ids, params=params) if not resource: - raise JIRAError("Unable to find resource %s(%s)", resource_cls, ids) + raise JIRAError("Unable to find resource %s(%s)", resource_cls, str(ids)) return resource def _try_magic(self): @@ -3175,34 +3361,34 @@ def cleanup(x): except AttributeError: self._magic = None - def _get_mime_type(self, buff): + def _get_mime_type(self, buff: bytes) -> Optional[str]: """Get the MIME type for a given stream of bytes - :param buff: Stream of bytes - :type buff: bytes + Args: + buff (bytes): Stream of bytes - :rtype: str + Returns: + Optional[str]: the MIME type """ if self._magic is not None: return self._magic.id_buffer(buff) else: try: - return mimetypes.guess_type("f." + imghdr.what(0, buff))[0] + return mimetypes.guess_type("f." + str(imghdr.what(0, buff)))[0] except (IOError, TypeError): - logging.warning( + self.log.warning( "Couldn't detect content type of avatar image" ". Specify the 'contentType' parameter explicitly." ) return None - def rename_user(self, old_user, new_user): + def rename_user(self, old_user: str, new_user: str): """Rename a Jira user. - :param old_user: Old username login - :type old_user: str - :param new_user: New username login - :type new_user: str + Args: + old_user (str): Old username login + new_user (str): New username login """ if self._version > (6, 0, 0): @@ -3211,7 +3397,7 @@ def rename_user(self, old_user, new_user): params = {"username": old_user} # raw displayName - logging.debug("renaming %s" % self.user(old_user).emailAddress) + self.log.debug("renaming %s" % self.user(old_user).emailAddress) r = self._session.put(url, params=params, data=json.dumps(payload)) raise_on_error(r) @@ -3220,14 +3406,14 @@ def rename_user(self, old_user, new_user): "Support for renaming users in Jira " "< 6.0.0 has been removed." ) - def delete_user(self, username): + def delete_user(self, username: str) -> bool: """Deletes a Jira User. - :param username: Username to delete - :type username: str + Args: + username (str): Username to delete - :return: Success of user deletion - :rtype: bool + Returns: + bool: Success of user deletion """ @@ -3237,16 +3423,17 @@ def delete_user(self, username): if 200 <= r.status_code <= 299: return True else: - logging.error(r.status_code) + self.log.error(r.status_code) return False - def deactivate_user(self, username): + def deactivate_user(self, username: str) -> Union[str, int]: """Disable/deactivate the user. - :param username: User to be deactivated. - :type username: str + Args: + username (str): User to be deactivated. - :rtype: Union[str, int] + Returns: + Union[str, int] """ if self.deploymentType == "Cloud": # Disabling users now needs cookie auth in the Cloud - see https://jira.atlassian.com/browse/ID-6230 @@ -3275,13 +3462,13 @@ def deactivate_user(self, username): if r.status_code == 200: return True else: - logging.warning( + self.log.warning( "Got response from deactivating %s: %s" % (username, r.status_code) ) return r.status_code except Exception as e: - logging.error("Error Deactivating %s: %s" % (username, e)) + self.log.error("Error Deactivating %s: %s" % (username, e)) raise JIRAError("Error Deactivating %s: %s" % (username, e)) else: url = self.server_url + "/secure/admin/user/EditUser.jspa" @@ -3304,22 +3491,26 @@ def deactivate_user(self, username): if r.status_code == 200: return True else: - logging.warning( + self.log.warning( "Got response from deactivating %s: %s" % (username, r.status_code) ) return r.status_code except Exception as e: - logging.error("Error Deactivating %s: %s" % (username, e)) + self.log.error("Error Deactivating %s: %s" % (username, e)) raise JIRAError("Error Deactivating %s: %s" % (username, e)) - def reindex(self, force=False, background=True): + def reindex(self, force: bool = False, background: bool = True) -> bool: """Start jira re-indexing. Returns True if reindexing is in progress or not needed, or False. If you call reindex() without any parameters it will perform a background reindex only if Jira thinks it should do it. - :param force: reindex even if Jira doesn't say this is needed, False by default. - :param background: reindex in background, slower but does not impact the users, defaults to True. + Args: + force (bool): reindex even if Jira doesn't say this is needed, False by default. + background (bool): reindex in background, slower but does not impact the users, defaults to True. + + Returns: + bool: Returns True if reindexing is in progress or not needed, or False. """ # /secure/admin/IndexAdmin.jspa # /secure/admin/jira/IndexProgress.jspa?taskId=1 @@ -3332,8 +3523,8 @@ def reindex(self, force=False, background=True): r = self._session.get(url, headers=self._options["headers"]) if r.status_code == 503: - # logging.warning("Jira returned 503, this could mean that a full reindex is in progress.") - return 503 + # self.log.warning("Jira returned 503, this could mean that a full reindex is in progress.") + return 503 # type: ignore # FIXME: is this a bug? if ( not r.text.find("To perform the re-index now, please go to the") @@ -3342,7 +3533,7 @@ def reindex(self, force=False, background=True): return True if r.text.find("All issues are being re-indexed"): - logging.warning("Jira re-indexing is already running.") + self.log.warning("Jira re-indexing is already running.") return True # still reindexing is considered still a success if r.text.find("To perform the re-index now, please go to the") or force: @@ -3353,12 +3544,13 @@ def reindex(self, force=False, background=True): ) if r.text.find("All issues are being re-indexed") != -1: return True - else: - logging.error("Failed to reindex jira, probably a bug.") - return False - def backup(self, filename="backup.zip", attachments=False): + self.log.error("Failed to reindex jira, probably a bug.") + return False + + def backup(self, filename: str = "backup.zip", attachments: bool = False): """Will call jira export to backup as zipped xml. Returning with success does not mean that the backup process finished.""" + payload: Any # _session.post is pretty open if self.deploymentType == "Cloud": url = self.server_url + "/rest/backup/1/export/runbackup" payload = json.dumps({"cbAttachments": attachments}) @@ -3371,10 +3563,10 @@ def backup(self, filename="backup.zip", attachments=False): if r.status_code == 200: return True else: - logging.warning("Got %s response from calling backup." % r.status_code) + self.log.warning("Got %s response from calling backup." % r.status_code) return r.status_code except Exception as e: - logging.error("I see %s", e) + self.log.error("I see %s", e) def backup_progress(self): """Return status of cloud backup as a dict. @@ -3385,7 +3577,7 @@ def backup_progress(self): if self.deploymentType == "Cloud": url = self.server_url + "/rest/obm/1.0/getprogress?_=%i" % epoch_time else: - logging.warning("This functionality is not available in Server version") + self.log.warning("This functionality is not available in Server version") return None r = self._session.get(url, headers=self._options["headers"]) # This is weird. I used to get xml, but now I'm getting json @@ -3398,7 +3590,7 @@ def backup_progress(self): try: root = etree.fromstring(r.text) except etree.ParseError as pe: - logging.warning( + self.log.warning( "Unable to find backup info. You probably need to initiate a new backup. %s" % pe ) @@ -3407,28 +3599,29 @@ def backup_progress(self): progress[k] = root.get(k) return progress - def backup_complete(self): + def backup_complete(self) -> Optional[bool]: """Return boolean based on 'alternativePercentage' and 'size' returned from backup_progress (cloud only).""" if self.deploymentType != "Cloud": - logging.warning("This functionality is not available in Server version") + self.log.warning("This functionality is not available in Server version") return None status = self.backup_progress() + perc_search = re.search(r"\s([0-9]*)\s", status["alternativePercentage"]) perc_complete = int( - re.search(r"\s([0-9]*)\s", status["alternativePercentage"]).group(1) + perc_search.group(1) # type: ignore # ignore that re.search can return None ) file_size = int(status["size"]) return perc_complete >= 100 and file_size > 0 - def backup_download(self, filename=None): + def backup_download(self, filename: str = None): """Download backup file from WebDAV (cloud only).""" if self.deploymentType != "Cloud": - logging.warning("This functionality is not available in Server version") + self.log.warning("This functionality is not available in Server version") return None remote_file = self.backup_progress()["fileName"] local_file = filename or remote_file url = self.server_url + "/webdav/backupmanager/" + remote_file try: - logging.debug("Writing file to %s" % local_file) + self.log.debug("Writing file to %s" % local_file) with open(local_file, "wb") as file: try: resp = self._session.get( @@ -3437,45 +3630,49 @@ def backup_download(self, filename=None): except Exception: raise JIRAError() if not resp.ok: - logging.error("Something went wrong with download: %s" % resp.text) + self.log.error("Something went wrong with download: %s" % resp.text) raise JIRAError(resp.text) for block in resp.iter_content(1024): file.write(block) except JIRAError as je: - logging.error("Unable to access remote backup file: %s" % je) + self.log.error("Unable to access remote backup file: %s" % je) except IOError as ioe: - logging.error(ioe) + self.log.error(ioe) return None - def current_user(self, field="key"): + def current_user(self, field: str = "key") -> str: """Returns the username or emailAddress of the current user. For anonymous users it will return a value that evaluates as False. - :rtype: str + Returns: + str """ if not hasattr(self, "_myself"): url = self._get_url("myself") r = self._session.get(url, headers=self._options["headers"]) - r_json = json_loads(r) + r_json: Dict[str, str] = json_loads(r) self._myself = r_json return self._myself[field] - def delete_project(self, pid): + def delete_project(self, pid: Union[str, Project]) -> Optional[bool]: """Delete project from Jira. - :param pid: Jira projectID or Project or slug - :type pid: str - :return: True if project was deleted - :rtype: bool - :raises JIRAError: If project not found or not enough permissions - :raises ValueError: If pid parameter is not Project, slug or ProjectID + Args: + pid (Union[str, Project]): Jira projectID or Project or slug + + Raises: + JIRAError: If project not found or not enough permissions + ValueError: If pid parameter is not Project, slug or ProjectID + + Returns: + bool: True if project was deleted """ # allows us to call it with Project objects - if hasattr(pid, "id"): - pid = pid.id + if isinstance(pid, Project) and hasattr(pid, "id"): + pid = str(pid.id) url = self._get_url("project/%s" % pid) r = self._session.delete(url) @@ -3508,12 +3705,12 @@ def _gain_sudo_session(self, options, destination): ) @lru_cache(maxsize=None) - def templates(self): + def templates(self) -> Dict: url = self.server_url + "/rest/project-templates/latest/templates" r = self._session.get(url) - data = json_loads(r) + data: Dict[str, Any] = json_loads(r) templates = {} if "projectTemplatesGroupedByType" in data: @@ -3529,9 +3726,9 @@ def permissionschemes(self): url = self._get_url("permissionscheme") r = self._session.get(url) - data = json_loads(r)["permissionSchemes"] + data: Dict[str, Any] = json_loads(r) - return data + return data["permissionSchemes"] @lru_cache(maxsize=None) def issuesecurityschemes(self): @@ -3539,9 +3736,9 @@ def issuesecurityschemes(self): url = self._get_url("issuesecurityschemes") r = self._session.get(url) - data = json_loads(r)["issueSecuritySchemes"] + data: Dict[str, Any] = json_loads(r) - return data + return data["issueSecuritySchemes"] @lru_cache(maxsize=None) def projectcategories(self): @@ -3559,9 +3756,9 @@ def avatars(self, entity="project"): url = self._get_url("avatar/%s/system" % entity) r = self._session.get(url) - data = json_loads(r)["system"] + data: Dict[str, Any] = json_loads(r) - return data + return data["system"] @lru_cache(maxsize=None) def notificationschemes(self): @@ -3569,7 +3766,7 @@ def notificationschemes(self): url = self._get_url("notificationscheme") r = self._session.get(url) - data = json_loads(r) + data: Dict[str, Any] = json_loads(r) return data["values"] @lru_cache(maxsize=None) @@ -3578,7 +3775,7 @@ def screens(self): url = self._get_url("screens") r = self._session.get(url) - data = json_loads(r) + data: Dict[str, Any] = json_loads(r) return data["values"] @lru_cache(maxsize=None) @@ -3599,7 +3796,7 @@ def workflows(self): data = json_loads(r) return data # ['values'] - def delete_screen(self, id): + def delete_screen(self, id: str): url = self._get_url("screens/%s" % id) @@ -3609,7 +3806,7 @@ def delete_screen(self, id): self.screens.cache_clear() return data - def delete_permissionscheme(self, id): + def delete_permissionscheme(self, id: str): url = self._get_url("permissionscheme/%s" % id) @@ -3621,35 +3818,31 @@ def delete_permissionscheme(self, id): def create_project( self, - key, - name=None, - assignee=None, - ptype="software", - template_name=None, + key: str, + name: str = None, + assignee: str = None, + ptype: str = "software", + template_name: str = None, avatarId=None, issueSecurityScheme=None, permissionScheme=None, projectCategory=None, notificationScheme=10000, categoryId=None, - url="", + url: str = "", ): """Create a project with the specified parameters. - :param key: Mandatory. Must match Jira project key requirements, usually only 2-10 uppercase characters. - :type: str - :param name: If not specified it will use the key value. - :type name: Optional[str] - :param assignee: key of the lead, if not specified it will use current user. - :type assignee: Optional[str] - :param type: Determines the type of project should be created. - :type ptype: Optional[str] - :param template_name: is used to create a project based on one of the existing project templates. - If `template_name` is not specified, then it should use one of the default values. - :type template_name: Optional[str] + Args: + key (str): Mandatory. Must match Jira project key requirements, usually only 2-10 uppercase characters. + name (Optional[str]): If not specified it will use the key value. + assignee (Optional[str]): key of the lead, if not specified it will use current user. + ptype (Optional[str]): Determines the type of project should be created. + template_name (Optional[str]): is used to create a project based on one of the existing project templates. + If `template_name` is not specified, then it should use one of the default values. - :return: Should evaluate to False if it fails otherwise it will be the new project id. - :rtype: Union[bool,int] + Returns: + Union[bool,int]: Should evaluate to False if it fails otherwise it will be the new project id. """ template_key = None @@ -3659,6 +3852,8 @@ def create_project( if name is None: name = key + ps_list: List[Dict[str, Any]] + if not permissionScheme: ps_list = self.permissionschemes() for sec in ps_list: @@ -3779,41 +3974,35 @@ def create_project( def add_user( self, - username, - email, - directoryId=1, - password=None, - fullname=None, - notify=False, - active=True, - ignore_existing=False, - application_keys=None, + username: str, + email: str, + directoryId: int = 1, + password: str = None, + fullname: str = None, + notify: bool = False, + active: bool = True, + ignore_existing: bool = False, + application_keys: Optional[List] = None, ): """Create a new Jira user. - :param username: the username of the new user - :type username: str - :param email: email address of the new user - :type email: str - :param directoryId: The directory ID the new user should be a part of (Default: 1) - :type directoryId: int - :param password: Optional, the password for the new user - :type password: Optional[str] - :param fullname: Optional, the full name of the new user - :type fullname: Optional[str] - :param notify: Whether or not to send a notification to the new user. (Default: False) - :type notify: bool - :param active: Whether or not to make the new user active upon creation. (Default: True) - :type active: bool - :param ignore_existing: Whether or not to ignore and existing user. (Default: False) - :type ignore_existing: bool - :param applicationKeys: Keys of products user should have access to - :type applicationKeys: Optional[list] - - :return: Whether or not the user creation was successful. - :rtype: bool - - :raises JIRAError: If username already exists and `ignore_existing` has not been set to `True`. + Args: + username (str): the username of the new user + email (str): email address of the new user + directoryId (int): The directory ID the new user should be a part of (Default: 1) + password (Optional[str]): Optional, the password for the new user + fullname (Optional[str]): Optional, the full name of the new user + notify (bool): Whether or not to send a notification to the new user. (Default: False) + active (bool): Whether or not to make the new user active upon creation. (Default: True) + ignore_existing (bool): Whether or not to ignore and existing user. (Default: False) + applicationKeys (Optional[list]): Keys of products user should have access to + + Raises: + JIRAError: If username already exists and `ignore_existing` has not been set to `True`. + + Returns: + bool: Whether or not the user creation was successful. + """ if not fullname: @@ -3824,7 +4013,7 @@ def add_user( # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 - x = OrderedDict() + x: Dict[str, Any] = OrderedDict() x["displayName"] = fullname x["emailAddress"] = email @@ -3840,26 +4029,28 @@ def add_user( try: self._session.post(url, data=payload) except JIRAError as e: - err = e.response.json()["errors"] - if ( - "username" in err - and err["username"] == "A user with that username already exists." - and ignore_existing - ): - return True + if e.response: + err = e.response.json()["errors"] + if ( + "username" in err + and err["username"] == "A user with that username already exists." + and ignore_existing + ): + return True raise e return True - def add_user_to_group(self, username, group): + def add_user_to_group( + self, username: str, group: str + ) -> Union[bool, Dict[str, Any]]: """Add a user to an existing group. - :param username: Username that will be added to specified group. - :type username: str - :param group: Group that the user will be added to. - :type group: str + Args: + username (str): Username that will be added to specified group. + group (str): Group that the user will be added to. - :return: json response from Jira server for success or a value that evaluates as False in case of failure. - :rtype: Union[bool,Dict[str,Any]] + Returns: + Union[bool,Dict[str,Any]]: json response from Jira server for success or a value that evaluates as False in case of failure. """ url = self._get_latest_url("group/user") x = {"groupname": group} @@ -3867,17 +4058,18 @@ def add_user_to_group(self, username, group): payload = json.dumps(y) - r = json_loads(self._session.post(url, params=x, data=payload)) + r: Dict[str, Any] = json_loads(self._session.post(url, params=x, data=payload)) if "name" not in r or r["name"] != group: return False else: return r - def remove_user_from_group(self, username, groupname): + def remove_user_from_group(self, username: str, groupname: str): """Remove a user from a group. - :param username: The user to remove from the group. - :param groupname: The group that the user will be removed from. + Args: + username (str): The user to remove from the group. + groupname (str): The group that the user will be removed from. """ url = self._get_latest_url("group/user") x = {"groupname": groupname, "username": username} @@ -3886,11 +4078,11 @@ def remove_user_from_group(self, username, groupname): return True - def role(self): + def role(self) -> List[Dict[str, Any]]: """Return Jira role information. - :return: List of current user roles - :rtype: Iterable + Returns: + List[Dict[str,Any]]: List of current user roles """ # https://developer.atlassian.com/cloud/jira/platform/rest/v3/?utm_source=%2Fcloud%2Fjira%2Fplatform%2Frest%2F&utm_medium=302#api-rest-api-3-role-get @@ -3898,12 +4090,13 @@ def role(self): url = self._get_latest_url("role") r = self._session.get(url) - return json_loads(r) + data: List[Dict[str, Any]] = json_loads(r) + return data # Experimental # Experimental support for iDalko Grid, expect API to change as it's using private APIs currently # https://support.idalko.com/browse/IGRID-1017 - def get_igrid(self, issueid, customfield, schemeid): + def get_igrid(self, issueid: str, customfield: str, schemeid: str): url = self.server_url + "/rest/idalko-igrid/1.0/datagrid/data" if str(customfield).isdigit(): customfield = "customfield_%s" % customfield @@ -3922,16 +4115,24 @@ def get_igrid(self, issueid, customfield, schemeid): @translate_resource_args def boards( - self, startAt=0, maxResults=50, type=None, name=None, projectKeyOrID=None - ): + self, + startAt: int = 0, + maxResults: int = 50, + type: str = None, + name: str = None, + projectKeyOrID=None, + ) -> ResultList[Board]: """Get a list of board resources. - :param startAt: The starting index of the returned boards. Base index: 0. - :param maxResults: The maximum number of boards to return per page. Default: 50 - :param type: Filters results to boards of the specified type. Valid values: scrum, kanban. - :param name: Filters results to boards that match or partially match the specified name. - :param projectKeyOrID: Filters results to boards that match the specified project key or ID. - :rtype: ResultList[Board] + Args: + startAt: The starting index of the returned boards. Base index: 0. + maxResults: The maximum number of boards to return per page. Default: 50 + type: Filters results to boards of the specified type. Valid values: scrum, kanban. + name: Filters results to boards that match or partially match the specified name. + projectKeyOrID: Filters results to boards that match the specified project key or ID. + + Returns: + ResultList[Board] When old GreenHopper private API is used, paging is not enabled and all parameters are ignored. """ @@ -3955,7 +4156,9 @@ def boards( Warning, ) - r_json = self._get_json("rapidviews/list", base=self.AGILE_BASE_URL) + r_json: Dict[str, Any] = self._get_json( + "rapidviews/list", base=self.AGILE_BASE_URL + ) boards = [ Board(self._options, self._session, raw_boards_json) for raw_boards_json in r_json["views"] @@ -3973,26 +4176,28 @@ def boards( ) @translate_resource_args - def sprints(self, board_id, extended=False, startAt=0, maxResults=50, state=None): + def sprints( + self, + board_id: int, + extended: bool = False, + startAt: int = 0, + maxResults: int = 50, + state: str = None, + ) -> ResultList[Sprint]: """Get a list of sprint GreenHopperResources. - :param board_id: the board to get sprints from - :param extended: Used only by old GreenHopper API to fetch additional information like - startDate, endDate, completeDate, much slower because it requires an additional requests for each sprint. - New Jira Agile API always returns this information without a need for additional requests. - :param startAt: the index of the first sprint to return (0 based) - :param maxResults: the maximum number of sprints to return - :param state: Filters results to sprints in specified states. Valid values: `future`, `active`, `closed`. - You can define multiple states separated by commas - - :type board_id: int - :type extended: bool - :type startAt: int - :type maxResults: int - :type state: str - - :rtype: list of :class:`~jira.resources.Sprint` - :return: (content depends on API version, but always contains id, name, state, startDate and endDate) + Args: + board_id (int): the board to get sprints from + extended (bool): Used only by old GreenHopper API to fetch additional information like + startDate, endDate, completeDate, much slower because it requires an additional requests for each sprint. + New Jira Agile API always returns this information without a need for additional requests. + startAt (int): the index of the first sprint to return (0 based) + maxResults (int): the maximum number of sprints to return + state (str): Filters results to sprints in specified states. Valid values: `future`, `active`, `closed`. + You can define multiple states separated by commas + + Returns: + ResultList[Sprint]: (content depends on API version, but always contains id, name, state, startDate and endDate) When old GreenHopper private API is used, paging is not enabled, and `startAt`, `maxResults` and `state` parameters are ignored. """ @@ -4004,7 +4209,7 @@ def sprints(self, board_id, extended=False, startAt=0, maxResults=50, state=None self._options["agile_rest_path"] == GreenHopperResource.GREENHOPPER_REST_PATH ): - r_json = self._get_json( + r_json: Dict[str, Any] = self._get_json( "sprintquery/%s?includeHistoricSprints=true&includeFutureSprints=true" % board_id, base=self.AGILE_BASE_URL, @@ -4022,7 +4227,7 @@ def sprints(self, board_id, extended=False, startAt=0, maxResults=50, state=None Sprint( self._options, self._session, - self.sprint_info(None, raw_sprints_json["id"]), + self.sprint_info("", raw_sprints_json["id"]), ) for raw_sprints_json in r_json["sprints"] ] @@ -4076,17 +4281,18 @@ def update_sprint(self, id, name=None, startDate=None, endDate=None, state=None) return json_loads(r) - def incompletedIssuesEstimateSum(self, board_id, sprint_id): + def incompletedIssuesEstimateSum(self, board_id: str, sprint_id: str): """Return the total incompleted points this sprint.""" - return self._get_json( + data: Dict[str, Any] = self._get_json( "rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s" % (board_id, sprint_id), base=self.AGILE_BASE_URL, - )["contents"]["incompletedIssuesEstimateSum"]["value"] + ) + return data["contents"]["incompletedIssuesEstimateSum"]["value"] - def removed_issues(self, board_id, sprint_id): + def removed_issues(self, board_id: str, sprint_id: str): """Return the completed issues for the sprint.""" - r_json = self._get_json( + r_json: Dict[str, Any] = self._get_json( "rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s" % (board_id, sprint_id), base=self.AGILE_BASE_URL, @@ -4098,33 +4304,35 @@ def removed_issues(self, board_id, sprint_id): return issues - def removedIssuesEstimateSum(self, board_id, sprint_id): + def removedIssuesEstimateSum(self, board_id: str, sprint_id: str): """Return the total incompleted points this sprint.""" - return self._get_json( + data: Dict[str, Any] = self._get_json( "rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s" % (board_id, sprint_id), base=self.AGILE_BASE_URL, - )["contents"]["puntedIssuesEstimateSum"]["value"] + ) + return data["contents"]["puntedIssuesEstimateSum"]["value"] # TODO(ssbarnea): remove sprint_info() method, sprint() method suit the convention more - def sprint_info(self, board_id, sprint_id): + def sprint_info(self, board_id: str, sprint_id: str) -> Optional[Dict[str, Any]]: """Return the information about a sprint. - :param board_id: the board retrieving issues from. Deprecated and ignored. - :param sprint_id: the sprint retrieving issues from + Args: + board_id (str): the board retrieving issues from. Deprecated and ignored. + sprint_id (str): the sprint retrieving issues from """ sprint = Sprint(self._options, self._session) sprint.find(sprint_id) return sprint.raw - def sprint(self, id): + def sprint(self, id: int) -> Sprint: """Return the information about a sprint. - :param sprint_id: the sprint retrieving issues from - - :type sprint_id: int + Args: + sprint_id (int): the sprint retrieving issues from - :rtype: :class:`~jira.resources.Sprint` + Returns: + Sprint """ sprint = Sprint(self._options, self._session) sprint.find(id) @@ -4137,24 +4345,25 @@ def delete_board(self, id): board.delete() def create_board( - self, name, project_ids, preset="scrum", location_type="user", location_id=None - ): + self, + name: str, + project_ids: Union[str, List[str]], + preset: str = "scrum", + location_type: str = "user", + location_id: Optional[str] = None, + ) -> Board: """Create a new board for the ``project_ids``. - :param name: name of the board - :type name: str - :param project_ids: the projects to create the board in - :type project_ids: str - :param preset: What preset to use for this board. (Default: scrum) - :type preset: kanban, scrum, diy - :param location_type: the location type. Available in cloud. (Default: user) - :type location_type: user, project - :param location_id: the id of project that the board should be - located under. Omit this for a 'user' location_type. Available in cloud. - :type location_id: Optional[str] - - :return: The newly created board - :rtype: Board + Args: + name (str): name of the board + project_ids (str): the projects to create the board in + preset (str): What preset to use for this board, options: kanban, scrum, diy. (Default: scrum) + location_type (str): the location type. Available in cloud. (Default: user) + location_id (Optional[str]): the id of project that the board should be located under. + Omit this for a 'user' location_type. Available in cloud. + + Returns: + Board: The newly created board """ if ( self._options["agile_rest_path"] @@ -4164,7 +4373,7 @@ def create_board( "Jira Agile Public API does not support this request" ) - payload = {} + payload: Dict[str, Any] = {} if isinstance(project_ids, str): ids = [] for p in project_ids.split(","): @@ -4174,7 +4383,7 @@ def create_board( location_id = self.project(location_id).id payload["name"] = name if isinstance(project_ids, str): - project_ids = project_ids.split(",") + project_ids = project_ids.split(",") # type: ignore # re-use of variable payload["projectIds"] = project_ids payload["preset"] = preset if self.deploymentType == "Cloud": @@ -4186,27 +4395,31 @@ def create_board( raw_issue_json = json_loads(r) return Board(self._options, self._session, raw=raw_issue_json) - def create_sprint(self, name, board_id, startDate=None, endDate=None): + def create_sprint( + self, + name: str, + board_id: int, + startDate: Optional[Any] = None, + endDate: Optional[Any] = None, + ) -> Sprint: """Create a new sprint for the ``board_id``. - :param name: Name of the sprint - :type name: str - :param board_id: Which board the sprint should be assigned. - :type board_id: int - :param startDate: Start date for the sprint. - :type startDate: Optional[Any] - :param endDate: End date for the sprint. - :type endDate: Optional[Any] + Args: + name (str): Name of the 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. - :return: The newly created Sprint - :rtype: Sprint + Returns: + Sprint: The newly created Sprint """ - payload = {"name": name} + payload: Dict[str, Any] = {"name": name} if startDate: payload["startDate"] = startDate if endDate: payload["endDate"] = endDate + raw_issue_json: Dict[str, Any] if ( self._options["agile_rest_path"] == GreenHopperResource.GREENHOPPER_REST_PATH @@ -4239,7 +4452,7 @@ def create_sprint(self, name, board_id, startDate=None, endDate=None): return Sprint(self._options, self._session, raw=raw_issue_json) - def add_issues_to_sprint(self, sprint_id, issue_keys): + def add_issues_to_sprint(self, sprint_id: int, issue_keys: List[str]) -> Response: """Add the issues in ``issue_keys`` to the ``sprint_id``. The sprint must be started but not completed. @@ -4252,18 +4465,18 @@ def add_issues_to_sprint(self, sprint_id, issue_keys): If a sprint was not started, then have to edit the marker and copy the rank of each issue too. - :param sprint_id: the sprint to add issues to - :type sprint_id: int - :param issue_keys: the issues to add to the sprint - :type issue_keys: List[str] + Args: + sprint_id (int): the sprint to add issues to + issue_keys (List[str]): the issues to add to the sprint - :rtype: Response + Returns: + Response """ if self._options["agile_rest_path"] == GreenHopperResource.AGILE_BASE_REST_PATH: url = self._get_url("sprint/%s/issue" % sprint_id, base=self.AGILE_BASE_URL) payload = {"issues": issue_keys} try: - self._session.post(url, data=json.dumps(payload)) + return self._session.post(url, data=json.dumps(payload)) except JIRAError as e: if e.status_code == 404: warnings.warn( @@ -4295,15 +4508,15 @@ def add_issues_to_sprint(self, sprint_id, issue_keys): % self._options["agile_rest_path"] ) - def add_issues_to_epic(self, epic_id, issue_keys, ignore_epics=True): + def add_issues_to_epic( + self, epic_id: str, issue_keys: str, ignore_epics: bool = True + ) -> Response: """Add the issues in ``issue_keys`` to the ``epic_id``. - :param epic_id: The ID for the epic where issues should be added. - :type epic_id: int - :param issue_keys: The issues to add to the epic - :type issue_keys: str - :param ignore_epics: ignore any issues listed in ``issue_keys`` that are epics. (Default: True) - :type ignore_epics: bool + Args: + epic_id (str): The ID for the epic where issues should be added. + issue_keys (str): The issues to add to the epic + ignore_epics (bool): ignore any issues listed in ``issue_keys`` that are epics. (Default: True) """ if ( @@ -4315,18 +4528,19 @@ def add_issues_to_epic(self, epic_id, issue_keys, ignore_epics=True): "Jira Agile Public API does not support this request" ) - data = {} + data: Dict[str, Any] = {} data["issueKeys"] = issue_keys data["ignoreEpics"] = ignore_epics url = self._get_url("epics/%s/add" % epic_id, base=self.AGILE_BASE_URL) return self._session.put(url, data=json.dumps(data)) # TODO(ssbarnea): Both GreenHopper and new Jira Agile API support moving more than one issue. - def rank(self, issue, next_issue): + def rank(self, issue: str, next_issue: str) -> Response: """Rank an issue before another using the default Ranking field, the one named 'Rank'. - :param issue: issue key of the issue to be ranked before the second one. - :param next_issue: issue key of the second issue. + Args: + issue (str): issue key of the issue to be ranked before the second one. + next_issue (str): issue key of the second issue. """ if not self._rank: for field in self.fields(): @@ -4377,19 +4591,20 @@ def rank(self, issue, next_issue): % self._options["agile_rest_path"] ) - def move_to_backlog(self, issue_keys): + def move_to_backlog(self, issue_keys: str) -> Response: """Move issues in ``issue_keys`` to the backlog, removing them from all sprints that have not been completed. - :param issue_keys: the issues to move to the backlog - :param issue_keys: str + Args: + issue_keys (str): the issues to move to the backlog - :raises JIRAError: If moving issues to backlog fails + Raises: + JIRAError: If moving issues to backlog fails """ if self._options["agile_rest_path"] == GreenHopperResource.AGILE_BASE_REST_PATH: url = self._get_url("backlog/issue", base=self.AGILE_BASE_URL) payload = {"issues": issue_keys} try: - self._session.post(url, data=json.dumps(payload)) + return self._session.post(url, data=json.dumps(payload)) except JIRAError as e: if e.status_code == 404: warnings.warn( diff --git a/jira/config.py b/jira/config.py index 6d8624d7c..10f666d13 100644 --- a/jira/config.py +++ b/jira/config.py @@ -10,28 +10,36 @@ import logging import os import sys +from typing import Optional from jira.client import JIRA def get_jira( - profile=None, - url="http://localhost:2990", - username="admin", - password="admin", + profile: Optional[str] = None, + url: str = "http://localhost:2990", + username: str = "admin", + password: str = "admin", appid=None, autofix=False, - verify=True, + verify: bool = True, ): """Return a JIRA object by loading the connection details from the `config.ini` file. - :param profile: The name of the section from config.ini file that stores server config url/username/password - :param url: URL of the Jira server - :param username: username to use for authentication - :param password: password to use for authentication - :param verify: boolean indicating whether SSL certificates should be verified - :return: JIRA -- an instance to a JIRA object. - :raises: EnvironmentError + Args: + profile (Optional[str]): The name of the section from config.ini file that stores server config url/username/password + url (str): URL of the Jira server + username (str): username to use for authentication + password (str): password to use for authentication + appid: appid + autofix: autofix + verify (bool): boolean indicating whether SSL certificates should be verified + + Returns: + JIRA: an instance to a JIRA object. + + Raises: + EnvironmentError Usage: diff --git a/jira/exceptions.py b/jira/exceptions.py index 532c90221..1f52bd3d9 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -2,6 +2,8 @@ import os import tempfile +from requests import Response + class JIRAError(Exception): """General error raised for all problems in operation of the client.""" @@ -12,26 +14,22 @@ class JIRAError(Exception): def __init__( self, - status_code=None, - text=None, - url=None, - request=None, - response=None, + text: str = None, + status_code: int = None, + url: str = None, + request: Response = None, + response: Response = None, **kwargs ): """Creates a JIRAError. - :param status_code: Status code for the error. - :type status_code: Optional[int] - :param text: Message for the error. - :type text: Optional[str] - :param url: Url related to the error. - :type url: Optional[str] - :param request: Request made related to the error. - :type request: Optional[Any] - :param response: Response received related to the error. - :type response: Optional[Response] - :type kwargs: **Any + Args: + text (Optional[str]): Message for the error. + status_code (Optional[int]): Status code for the error. + url (Optional[str]): Url related to the error. + request (Optional[requests.Response]): Request made related to the error. + response (Optional[requests.Response]): Response received related to the error. + **kwargs: Will be used to get request headers. """ self.status_code = status_code self.text = text @@ -42,10 +40,11 @@ def __init__( self.log_to_tempfile = "PYJIRA_LOG_TO_TEMPFILE" in os.environ self.ci_run = "GITHUB_ACTION" in os.environ - def __str__(self): + def __str__(self) -> str: """Return a string representation of the error. - :rtype: str + Returns: + str """ t = "JiraError HTTP %s" % self.status_code if self.url: diff --git a/jira/py.typed b/jira/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/jira/resilientsession.py b/jira/resilientsession.py index 9286fd70a..2fa48de6d 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -3,8 +3,9 @@ import logging import random import time +from typing import Callable, Optional, Union, cast -from requests import Session +from requests import Response, Session from requests.exceptions import ConnectionError from jira.exceptions import JIRAError @@ -12,7 +13,18 @@ logging.getLogger("jira").addHandler(logging.NullHandler()) -def raise_on_error(r, verb="???", **kwargs): +def raise_on_error(r: Optional[Response], verb="???", **kwargs): + """Handle errors from a Jira Request + + Args: + r (Optional[Response]): Response from Jira request + verb (Optional[str]): Request type, e.g. POST. Defaults to "???". + + Raises: + JIRAError: If Response is None + JIRAError: for unhandled 400 status codes. + JIRAError: for unhandled 200 status codes. + """ request = kwargs.get("request", None) # headers = kwargs.get('headers', None) @@ -51,11 +63,18 @@ def raise_on_error(r, verb="???", **kwargs): except ValueError: error = r.text raise JIRAError( - r.status_code, error, r.url, request=request, response=r, **kwargs + error, + status_code=r.status_code, + url=r.url, + request=request, + response=r, + **kwargs, ) # for debugging weird errors on CI if r.status_code not in [200, 201, 202, 204]: - raise JIRAError(r.status_code, request=request, response=r, **kwargs) + raise JIRAError( + status_code=r.status_code, request=request, response=r, **kwargs + ) # testing for the bug exposed on # https://answers.atlassian.com/questions/11457054/answers/11975162 if ( @@ -81,8 +100,14 @@ def __init__(self, timeout=None): # Indicate our preference for JSON to avoid https://bitbucket.org/bspeakmon/jira-python/issue/46 and https://jira.atlassian.com/browse/JRA-38551 self.headers.update({"Accept": "application/json,*.*;q=0.9"}) - def __recoverable(self, response, url, request, counter=1): - msg = response + def __recoverable( + self, + response: Optional[Union[ConnectionError, Response]], + url: str, + request, + counter: int = 1, + ): + msg = str(response) if isinstance(response, ConnectionError): logging.warning( "Got ConnectionError [%s] errno:%s on %s %s\n%s\n%s" @@ -95,10 +120,10 @@ def __recoverable(self, response, url, request, counter=1): response.__dict__, ) ) - if hasattr(response, "status_code"): + if isinstance(response, Response): if response.status_code in [502, 503, 504, 401]: # 401 UNAUTHORIZED still randomly returned by Atlassian Cloud as of 2017-01-16 - msg = "%s %s" % (response.status_code, response.reason) + msg = f"{response.status_code} {response.reason}" # 2019-07-25: Disabled recovery for codes above^ return False elif not ( @@ -117,12 +142,15 @@ def __recoverable(self, response, url, request, counter=1): "Got recoverable error from %s %s, will retry [%s/%s] in %ss. Err: %s" % (request, url, counter, self.max_retries, delay, msg) ) - logging.debug("response.headers: %s", response.headers) - logging.debug("response.body: %s", response.content) + if isinstance(response, Response): + logging.debug("response.headers: %s", response.headers) + logging.debug("response.body: %s", response.content) time.sleep(delay) return True - def __verb(self, verb, url, retry_data=None, **kwargs): + def __verb( + self, verb: str, url: str, retry_data: Callable = None, **kwargs + ) -> Response: d = self.headers.copy() d.update(kwargs.get("headers", {})) @@ -135,6 +163,8 @@ def __verb(self, verb, url, retry_data=None, **kwargs): data = json.dumps(data) retry_number = 0 + exception = None + response = None while retry_number <= self.max_retries: response = None exception = None @@ -166,25 +196,27 @@ def __verb(self, verb, url, retry_data=None, **kwargs): if exception is not None: raise exception raise_on_error(response, verb=verb, **kwargs) + # after raise_on_error, only Response objects are allowed through + response = cast(Response, response) # tell mypy only Response-like are here return response - def get(self, url, **kwargs): - return self.__verb("GET", url, **kwargs) + def get(self, url: Union[str, bytes], **kwargs) -> Response: + return self.__verb("GET", str(url), **kwargs) - def post(self, url, **kwargs): - return self.__verb("POST", url, **kwargs) + def post(self, url: Union[str, bytes], data=None, json=None, **kwargs) -> Response: + return self.__verb("POST", str(url), data=data, json=json, **kwargs) - def put(self, url, **kwargs): - return self.__verb("PUT", url, **kwargs) + def put(self, url: Union[str, bytes], data=None, **kwargs) -> Response: + return self.__verb("PUT", str(url), data=data, **kwargs) - def delete(self, url, **kwargs): - return self.__verb("DELETE", url, **kwargs) + def delete(self, url: Union[str, bytes], **kwargs) -> Response: + return self.__verb("DELETE", str(url), **kwargs) - def head(self, url, **kwargs): - return self.__verb("HEAD", url, **kwargs) + def head(self, url: Union[str, bytes], **kwargs) -> Response: + return self.__verb("HEAD", str(url), **kwargs) - def patch(self, url, **kwargs): - return self.__verb("PATCH", url, **kwargs) + def patch(self, url: Union[str, bytes], data=None, **kwargs) -> Response: + return self.__verb("PATCH", str(url), data=data, **kwargs) - def options(self, url, **kwargs): - return self.__verb("OPTIONS", url, **kwargs) + def options(self, url: Union[str, bytes], **kwargs) -> Response: + return self.__verb("OPTIONS", str(url), **kwargs) diff --git a/jira/resources.py b/jira/resources.py index 435cce6e7..29b2fabb3 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -8,9 +8,16 @@ import logging import re import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union, cast +from requests import Response + +from jira.resilientsession import ResilientSession from jira.utils import CaseInsensitiveDict, json_loads, threaded_requests +if TYPE_CHECKING: + from jira.client import JIRA + __all__ = ( "Resource", "Issue", @@ -44,14 +51,14 @@ logging.getLogger("jira").addHandler(logging.NullHandler()) -def get_error_list(r): +def get_error_list(r: Response) -> List[str]: error_list = [] if r.status_code >= 400: if r.status_code == 403 and "x-authentication-denied-reason" in r.headers: error_list = [r.headers["x-authentication-denied-reason"]] elif r.text: try: - response = json_loads(r) + response: Dict[str, Any] = json_loads(r) if "message" in response: # Jira 5.1 errors error_list = [response["message"]] @@ -60,7 +67,7 @@ def get_error_list(r): # Sometimes this is present but empty errorMessages = response["errorMessages"] if isinstance(errorMessages, (list, tuple)): - error_list = errorMessages + error_list = list(errorMessages) else: error_list = [errorMessages] elif "errors" in response and len(response["errors"]) > 0: @@ -115,16 +122,20 @@ class Resource(object): "closed", ) - def __init__(self, resource, options, session, base_url=JIRA_BASE_URL): + def __init__( + self, + resource: str, + options: Dict[str, Any], + session: ResilientSession, + base_url: str = JIRA_BASE_URL, + ): """Initializes a generic resource. - :param resource: The name of the resource. - :type resource: str - :param options: Options for the new resource - :type options: Dict[str,str] - :param session: Session used for the resource. - :type session: ResilientSession - :param base_url: The Base Jira url. - :type base_url: Optional[str] + + Args: + resource (str): The name of the resource. + options (Dict[str,str]): Options for the new resource + session (ResilientSession): Session used for the resource. + base_url (Optional[str]): The Base Jira url. """ self._resource = resource @@ -134,12 +145,13 @@ def __init__(self, resource, options, session, base_url=JIRA_BASE_URL): # Explicitly define as None so we know when a resource has actually # been loaded - self.raw = None + self.raw: Optional[Dict[str, Any]] = None - def __str__(self): + def __str__(self) -> str: """Return the first value we find that is likely to be human readable. - :rtype: str + Returns: + str """ if self.raw: for name in self._READABLE_IDS: @@ -153,12 +165,13 @@ def __str__(self): # If all else fails, use repr to make sure we get something. return repr(self) - def __repr__(self): + def __repr__(self) -> str: """Identify the class and include any and all relevant values. - :rtype: str + Returns: + str """ - names = [] + names: List[str] = [] if self.raw: for name in self._READABLE_IDS: if name in self.raw: @@ -167,20 +180,21 @@ def __repr__(self): return "" % (self.__class__.__name__, id(self)) return "" % (self.__class__.__name__, ", ".join(names)) - def __getattr__(self, item): + def __getattr__(self, item: str): """Allow access of attributes via names. - :param item: Attribute name - :type item: str + Args: + item (str): Attribute Name - :rtype: Any - - :raises KeyError: When the attribute does not exist. - :raises AttributeError: When attribute does not exist. + Raises: + KeyError: When the attribute does not exist. + AttributeError: When attribute does not exist. + Returns: + Any: Attribute value. """ try: - return self[item] + return self[item] # type: ignore except Exception as e: # Make sure pickling doesn't break # *MORE INFO*: This conditional wouldn't be necessary if __getattr__ wasn't used. But @@ -193,7 +207,7 @@ def __getattr__(self, item): if item == "__getnewargs__": raise KeyError(item) - if hasattr(self, "raw") and item in self.raw: + if hasattr(self, "raw") and self.raw is not None and item in self.raw: return self.raw[item] else: raise AttributeError( @@ -213,11 +227,16 @@ def __getattr__(self, item): # self._parse_raw(raw_pickled) # - def find(self, id, params=None): + def find( + self, + id: Union[Tuple[str, str], int, str], + params: Optional[Dict[str, str]] = None, + ): """Finds a resource based on the input parameters. - :type id: Union[Tuple[str, str], int, str] - :type params: Optional[Dict[str, str]] + Args: + id (Union[Tuple[str, str], int, str]): id + params (Optional[Dict[str, str]]): params """ @@ -231,51 +250,54 @@ def find(self, id, params=None): url = self._get_url(path) self._load(url, params=params) - def _get_url(self, path): + def _get_url(self, path: str) -> str: """Gets the url for the specified path. - :type path: str - - :rtype: str + Args: + path (str): str + Returns: + str """ options = self._options.copy() options.update({"path": path}) return self._base_url.format(**options) - def update(self, fields=None, async_=None, jira=None, notify=True, **kwargs): + def update( + self, + fields: Optional[Dict[str, Any]] = None, + async_: Optional[bool] = None, + jira: "JIRA" = None, + notify: bool = True, + **kwargs: Any + ): """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. - :param fields: Fields which should be updated for the object. - :type fields: Optional[Dict[str, Any]] - :param async_: If true the request will be added to the queue so it can be executed later using async_run() - :type async_: bool - :param jira: Instance of Jira Client - :type jira: jira.JIRA - :param notify: Whether or not to notify users about the update. (Default: True) - :type notify: bool - :type kwargs: **Any + Args: + fields (Optional[Dict[str, Any]]): Fields which should be updated for the object. + async_ (bool): If true the request will be added to the queue so it can be executed later using async_run() + jira (jira.client.JIRA): Instance of Jira Client + notify (bool): Whether or not to notify users about the update. (Default: True) + kwargs (Any): extra arguments to the PUT request. """ if async_ is None: - async_ = self._options["async"] + async_: bool = self._options["async"] # type: ignore # redefinition data = {} if fields is not None: data.update(fields) data.update(kwargs) - data = json.dumps(data) - if not notify: querystring = "?notifyUsers=false" else: querystring = "" - r = self._session.put(self.self + querystring, data=data) + r = self._session.put(self.self + querystring, data=json.dumps(data)) if "autofix" in self._options and r.status_code == 400: user = None error_list = get_error_list(r) @@ -330,7 +352,7 @@ def update(self, fields=None, async_=None, jira=None, notify=True, **kwargs): else: raise NotImplementedError() - if user: + if user and jira: logging.warning( "Trying to add missing orphan user '%s' in order to complete the previous failed operation." % user @@ -340,11 +362,13 @@ def update(self, fields=None, async_=None, jira=None, notify=True, **kwargs): # logging.warning("autofix: setting assignee to '%s' and retrying the update." % self._options['autofix']) # data['fields']['assignee'] = {'name': self._options['autofix']} # EXPERIMENTAL ---> - if async_: + if async_: # FIXME: no async if not hasattr(self._session, "_async_jobs"): - self._session._async_jobs = set() - self._session._async_jobs.add( - threaded_requests.put(self.self, data=json.dumps(data)) + self._session._async_jobs = set() # type: ignore + self._session._async_jobs.add( # type: ignore + threaded_requests.put( # type: ignore + self.self, data=json.dumps(data) + ) ) else: r = self._session.put(self.self, data=json.dumps(data)) @@ -352,35 +376,47 @@ def update(self, fields=None, async_=None, jira=None, notify=True, **kwargs): time.sleep(self._options["delay_reload"]) self._load(self.self) - def delete(self, params=None): + def delete(self, params: Optional[Dict[str, Any]] = None) -> Optional[Response]: """Delete this resource from the server, passing the specified query parameters. If this resource doesn't support ``DELETE``, a :py:exc:`.JIRAError` will be raised; subclasses that specialize this method will only raise errors in case of user error. - :param params: Parameters for the delete request. - :type params: Optional[Dict[str, Any]] + Args: + params: Parameters for the delete request. - :rtype: Response + Returns: + Optional[Response]: Returns None if async """ if self._options["async"]: + # FIXME: mypy doesn't think this should work if not hasattr(self._session, "_async_jobs"): - self._session._async_jobs = set() - self._session._async_jobs.add( - threaded_requests.delete(url=self.self, params=params) + self._session._async_jobs = set() # type: ignore + self._session._async_jobs.add( # type: ignore + threaded_requests.delete(url=self.self, params=params) # type: ignore ) + return None else: return self._session.delete(url=self.self, params=params) - def _load(self, url, headers=CaseInsensitiveDict(), params=None, path=None): + def _load( + self, + url: str, + headers=CaseInsensitiveDict(), + params: Optional[Dict[str, str]] = None, + path: Optional[str] = None, + ): """Load a resource. - :type url: str - :type headers: CaseInsensitiveDict - :type params: Optional[Dict[str,str]] - :type path: Optional[str] + Args: + url (str): url + headers (Optional[CaseInsensitiveDict]): headers. Defaults to CaseInsensitiveDict(). + params (Optional[Dict[str,str]]): params to get request. Defaults to None. + path (Optional[str]): field to get. Defaults to None. + Raises: + ValueError: If json cannot be loaded """ r = self._session.get(url, headers=headers, params=params) try: @@ -392,10 +428,11 @@ def _load(self, url, headers=CaseInsensitiveDict(), params=None, path=None): j = j[path] self._parse_raw(j) - def _parse_raw(self, raw): + def _parse_raw(self, raw: Dict[str, Any]): """Parse a raw dictionary to create a resource. - :type raw: Dict[str, Any] + Args: + raw (Dict[str, Any]) """ self.raw = raw if not raw: @@ -413,7 +450,12 @@ def _default_headers(self, user_headers): class Attachment(Resource): """An issue attachment.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "attachment/{0}", options, session) if raw: self._parse_raw(raw) @@ -432,15 +474,21 @@ def iter_content(self, chunk_size=1024): class Component(Resource): """A project component.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "component/{0}", options, session) if raw: self._parse_raw(raw) - def delete(self, moveIssuesTo=None): + def delete(self, moveIssuesTo: Optional[str] = None): # type: ignore[override] """Delete this component from the server. - :param moveIssuesTo: the name of the component to which to move any issues this component is applied + Args: + moveIssuesTo: the name of the component to which to move any issues this component is applied """ params = {} if moveIssuesTo is not None: @@ -452,7 +500,12 @@ def delete(self, moveIssuesTo=None): class CustomFieldOption(Resource): """An existing option for a custom issue field.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "customFieldOption/{0}", options, session) if raw: self._parse_raw(raw) @@ -461,7 +514,12 @@ def __init__(self, options, session, raw=None): class Dashboard(Resource): """A Jira dashboard.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "dashboard/{0}", options, session) if raw: self._parse_raw(raw) @@ -470,7 +528,12 @@ def __init__(self, options, session, raw=None): class Filter(Resource): """An issue navigator filter.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "filter/{0}", options, session) if raw: self._parse_raw(raw) @@ -480,34 +543,45 @@ class Issue(Resource): """A Jira issue.""" class _IssueFields(object): + class _Comment(object): + def __init__(self) -> None: + self.comments: List[Comment] = [] + + class _Worklog(object): + def __init__(self) -> None: + self.worklogs: List[Worklog] = [] + def __init__(self): - self.attachment = None - """ :type : list[Attachment] """ - self.description = None - """ :type : str """ - self.project = None - """ :type : Project """ - self.comment = None - """ :type : list[Comment] """ - self.issuelinks = None - """ :type : list[IssueLink] """ - self.worklog = None - """ :type : list[Worklog] """ - - def __init__(self, options, session, raw=None): + self.attachment: List[Attachment] = [] + self.comment = self._Comment() + self.description: Optional[str] = None + self.issuelinks: List[IssueLink] = [] + self.labels: List[str] = [] + self.project: Optional[Project] = None + self.worklog = self._Worklog() + + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issue/{0}", options, session) - self.fields = None - """ :type: :class:`~Issue._IssueFields` """ - self.id = None - """ :type: int """ - self.key = None - """ :type: str """ + self.fields: Issue._IssueFields + self.id: str + self.key: str if raw: self._parse_raw(raw) - def update( - self, fields=None, update=None, async_=None, jira=None, notify=True, **fieldargs + def update( # type: ignore[override] # incompatible supertype ignored + self, + fields: Dict[str, Any] = None, + update: Dict[str, Any] = None, + async_: bool = None, + jira: "JIRA" = None, + notify: bool = True, + **fieldargs ): """Update this issue on the server. @@ -519,15 +593,14 @@ def update( fields in an issue. This information is available through the :py:meth:`.JIRA.editmeta` method. Further examples are available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Edit+issues - :param fields: a dict containing field names and the values to use - :param update: a dict containing update operations to apply - :param notify: query parameter notifyUsers. If true send the email with notification that the issue was updated - to users that watch it. Admin or project admin permissions are required to disable the notification. - :param fieldargs: keyword arguments will generally be merged into fields, except lists, - which will be merged into updates - - :type fields: dict - :type update: dict + Args: + fields (Dict[str,Any]): a dict containing field names and the values to use + update (Dict[str,Any]): a dict containing update operations to apply + notify (bool): query parameter notifyUsers. If true send the email with notification that the issue was updated + to users that watch it. Admin or project admin permissions are required to disable the notification. + jira (Optional[jira.client.JIRA]): JIRA instance. + fieldargs (dict): keyword arguments will generally be merged into fields, except lists, + which will be merged into updates """ data = {} @@ -562,33 +635,32 @@ def update( super(Issue, self).update(async_=async_, jira=jira, notify=notify, fields=data) - def add_field_value(self, field, value): + def add_field_value(self, field: str, value: str): """Add a value to a field that supports multiple values, without resetting the existing values. This should work with: labels, multiple checkbox lists, multiple select - :param field: The field name - :param value: The field's value + Args: + field (str): The field name + value (str): The field's value - :type field: str """ super(Issue, self).update(fields={"update": {field: [{"add": value}]}}) def delete(self, deleteSubtasks=False): """Delete this issue from the server. - :param deleteSubtasks: if the issue has subtasks, this argument must be set to true for the call to succeed. + Args: + deleteSubtasks (bool): if the issue has subtasks, this argument must be set to true for the call to succeed. - :type deleteSubtasks: bool """ super(Issue, self).delete(params={"deleteSubtasks": deleteSubtasks}) def permalink(self): """Get the URL of the issue, the browsable one not the REST one. - :return: URL of the issue - - :rtype: str + Returns: + str: URL of the issue """ return "%s/browse/%s" % (self._options["server"], self.key) @@ -600,7 +672,12 @@ def __eq__(self, other): class Comment(Resource): """An issue comment.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issue/{0}/comment/{1}", options, session) if raw: self._parse_raw(raw) @@ -618,7 +695,12 @@ def update(self, fields=None, async_=None, jira=None, body="", visibility=None): class RemoteLink(Resource): """A link to a remote application from an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issue/{0}/remotelink/{1}", options, session) if raw: self._parse_raw(raw) @@ -629,10 +711,11 @@ def update(self, object, globalId=None, application=None, relationship=None): For definitions of the allowable fields for 'object' and the keyword arguments 'globalId', 'application' and 'relationship', see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links. - :param object: the link details to add (see the above link for details) - :param globalId: unique ID for the link (see the above link for details) - :param application: application information for the link (see the above link for details) - :param relationship: relationship description for the link (see the above link for details) + Args: + object: the link details to add (see the above link for details) + globalId: unique ID for the link (see the above link for details) + application: application information for the link (see the above link for details) + relationship: relationship description for the link (see the above link for details) """ data = {"object": object} if globalId is not None: @@ -648,7 +731,12 @@ def update(self, object, globalId=None, application=None, relationship=None): class Votes(Resource): """Vote information on an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issue/{0}/votes", options, session) if raw: self._parse_raw(raw) @@ -657,7 +745,12 @@ def __init__(self, options, session, raw=None): class Watchers(Resource): """Watcher information on an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issue/{0}/watchers", options, session) if raw: self._parse_raw(raw) @@ -668,7 +761,12 @@ def delete(self, username): class TimeTracking(Resource): - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) self.remainingEstimate = None if raw: @@ -678,19 +776,27 @@ def __init__(self, options, session, raw=None): class Worklog(Resource): """Worklog on an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) if raw: self._parse_raw(raw) - def delete(self, adjustEstimate=None, newEstimate=None, increaseBy=None): + def delete( # type: ignore[override] + self, adjustEstimate: Optional[str] = None, newEstimate=None, increaseBy=None + ): """Delete this worklog entry from its associated issue. - :param adjustEstimate: one of ``new``, ``leave``, ``manual`` or ``auto``. - ``auto`` is the default and adjusts the estimate automatically. - ``leave`` leaves the estimate unchanged by this deletion. - :param newEstimate: combined with ``adjustEstimate=new``, set the estimate to this value - :param increaseBy: combined with ``adjustEstimate=manual``, increase the remaining estimate by this amount + Args: + adjustEstimate: one of ``new``, ``leave``, ``manual`` or ``auto``. + ``auto`` is the default and adjusts the estimate automatically. + ``leave`` leaves the estimate unchanged by this deletion. + newEstimate: combined with ``adjustEstimate=new``, set the estimate to this value + increaseBy: combined with ``adjustEstimate=manual``, increase the remaining estimate by this amount """ params = {} if adjustEstimate is not None: @@ -706,7 +812,12 @@ def delete(self, adjustEstimate=None, newEstimate=None, increaseBy=None): class IssueLink(Resource): """Link between two issues.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issueLink/{0}", options, session) if raw: self._parse_raw(raw) @@ -715,7 +826,12 @@ def __init__(self, options, session, raw=None): class IssueLinkType(Resource): """Type of link between two issues.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issueLinkType/{0}", options, session) if raw: self._parse_raw(raw) @@ -724,7 +840,12 @@ def __init__(self, options, session, raw=None): class IssueType(Resource): """Type of an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "issuetype/{0}", options, session) if raw: self._parse_raw(raw) @@ -733,7 +854,12 @@ def __init__(self, options, session, raw=None): class Priority(Resource): """Priority that can be set on an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "priority/{0}", options, session) if raw: self._parse_raw(raw) @@ -742,7 +868,12 @@ def __init__(self, options, session, raw=None): class Project(Resource): """A Jira project.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "project/{0}", options, session) if raw: self._parse_raw(raw) @@ -751,18 +882,26 @@ def __init__(self, options, session, raw=None): class Role(Resource): """A role inside a project.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "project/{0}/role/{1}", options, session) if raw: self._parse_raw(raw) - def update(self, users=None, groups=None): + def update( # type: ignore[override] + self, + users: Union[str, List, Tuple] = None, + groups: Union[str, List, Tuple] = None, + ): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. - :param users: a user or users to add to the role - :type users: string, list or tuple - :param groups: a group or groups to add to the role - :type groups: string, list or tuple + Args: + users (Optional[Union[str,List,Tuple]]): a user or users to add to the role + groups (Optional[Union[str,List,Tuple]]): a group or groups to add to the role """ if users is not None and isinstance(users, str): @@ -780,15 +919,18 @@ def update(self, users=None, groups=None): super(Role, self).update(**data) - def add_user(self, users=None, groups=None): + def add_user( + self, + users: Union[str, List, Tuple] = None, + groups: Union[str, List, Tuple] = None, + ): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. - :param users: a user or users to add to the role - :type users: string, list or tuple - :param groups: a group or groups to add to the role - :type groups: string, list or tuple + Args: + users (Optional[Union[str,List,Tuple]]): a user or users to add to the role + groups (Optional[Union[str,List,Tuple]]): a group or groups to add to the role """ if users is not None and isinstance(users, str): @@ -796,14 +938,19 @@ def add_user(self, users=None, groups=None): if groups is not None and isinstance(groups, str): groups = (groups,) - data = {"user": users} + data = {"user": users} # FIXME: groups is not used. self._session.post(self.self, data=json.dumps(data)) class Resolution(Resource): """A resolution for an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "resolution/{0}", options, session) if raw: self._parse_raw(raw) @@ -812,7 +959,12 @@ def __init__(self, options, session, raw=None): class SecurityLevel(Resource): """A security level for an issue or project.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "securitylevel/{0}", options, session) if raw: self._parse_raw(raw) @@ -821,7 +973,12 @@ def __init__(self, options, session, raw=None): class Status(Resource): """Status for an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "status/{0}", options, session) if raw: self._parse_raw(raw) @@ -830,7 +987,12 @@ def __init__(self, options, session, raw=None): class StatusCategory(Resource): """StatusCategory for an issue.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "statuscategory/{0}", options, session) if raw: self._parse_raw(raw) @@ -839,7 +1001,12 @@ def __init__(self, options, session, raw=None): class User(Resource): """A Jira user.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "user?username={0}", options, session) if raw: self._parse_raw(raw) @@ -856,7 +1023,12 @@ def __eq__(self, other): class Group(Resource): """A Jira user group.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "group?groupname={0}", options, session) if raw: self._parse_raw(raw) @@ -873,7 +1045,12 @@ def __eq__(self, other): class Version(Resource): """A version of a project.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "version/{0}", options, session) if raw: self._parse_raw(raw) @@ -885,10 +1062,11 @@ def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): If neither of the arguments are specified, the version is removed from all issues it is attached to. - :param moveFixIssuesTo: in issues for which this version is a fix - version, add this argument version to the fix version list - :param moveAffectedIssuesTo: in issues for which this version is an - affected version, add this argument version to the affected version list + Args: + moveFixIssuesTo: in issues for which this version is a fix + version, add this argument version to the fix version list + moveAffectedIssuesTo: in issues for which this version is an + affected version, add this argument version to the affected version list """ params = {} @@ -948,7 +1126,13 @@ class GreenHopperResource(Resource): AGILE_BASE_REST_PATH = "agile" """ Public API introduced in Jira Agile 6.7.7. """ - def __init__(self, path, options, session, raw): + def __init__( + self, + path: str, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): self.self = None Resource.__init__(self, path, options, session, self.AGILE_BASE_URL) @@ -962,7 +1146,12 @@ def __init__(self, path, options, session, raw): class Sprint(GreenHopperResource): """A GreenHopper sprint.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): GreenHopperResource.__init__(self, "sprint/{0}", options, session, raw) def find(self, id, params=None): @@ -980,7 +1169,12 @@ def find(self, id, params=None): class Board(GreenHopperResource): """A GreenHopper board.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): path = ( "rapidview/{0}" if options["agile_rest_path"] == self.GREENHOPPER_REST_PATH @@ -1006,7 +1200,12 @@ def delete(self, params=None): class Customer(Resource): """A Service Desk customer.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__( self, "customer", options, session, "{server}/rest/servicedeskapi/{path}" ) @@ -1017,7 +1216,12 @@ def __init__(self, options, session, raw=None): class ServiceDesk(Resource): """A Service Desk.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__( self, "servicedesk/{0}", @@ -1032,7 +1236,12 @@ def __init__(self, options, session, raw=None): class RequestType(Resource): """A Service Desk Request Type.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): if raw: self._parse_raw(raw) @@ -1048,7 +1257,9 @@ def __init__(self, options, session, raw=None): # Utilities -def dict2resource(raw, top=None, options=None, session=None): +def dict2resource( + raw: Dict[str, Any], top=None, options=None, session=None +) -> Union["PropertyHolder", Type[Resource]]: """Convert a dictionary into a Jira Resource object. Recursively walks a dict structure, transforming the properties into attributes @@ -1062,19 +1273,36 @@ def dict2resource(raw, top=None, options=None, session=None): for i, j in raw.items(): if isinstance(j, dict): if "self" in j: - resource = cls_for_resource(j["self"])(options, session, j) + # to try and help mypy know that cls_for_resource can never be 'Resource' + resource_class = cast(Type[Resource], cls_for_resource(j["self"])) + resource = cast( + Type[Resource], + resource_class( # type: ignore + options=options, session=session, raw=j # type: ignore + ), + ) setattr(top, i, resource) elif i == "timetracking": setattr(top, "timetracking", TimeTracking(options, session, j)) else: setattr(top, i, dict2resource(j, options=options, session=session)) elif isinstance(j, seqs): - seq_list = [] + j = cast(List[Dict[str, Any]], j) # help mypy + seq_list: List[Any] = [] for seq_elem in j: if isinstance(seq_elem, dict): if "self" in seq_elem: - resource = cls_for_resource(seq_elem["self"])( - options, session, seq_elem + # to try and help mypy know that cls_for_resource can never be 'Resource' + resource_class = cast( + Type[Resource], cls_for_resource(seq_elem["self"]) + ) + resource = cast( + Type[Resource], + resource_class( # type: ignore + options=options, + session=session, + raw=seq_elem, # type: ignore + ), ) seq_list.append(resource) else: @@ -1089,7 +1317,7 @@ def dict2resource(raw, top=None, options=None, session=None): return top -resource_class_map = { +resource_class_map: Dict[str, Type[Resource]] = { # Jira-specific resources r"attachment/[^/]+$": Attachment, r"component/[^/]+$": Component, @@ -1123,13 +1351,18 @@ def dict2resource(raw, top=None, options=None, session=None): class UnknownResource(Resource): """A Resource from Jira that is not (yet) supported.""" - def __init__(self, options, session, raw=None): + def __init__( + self, + options: Dict[str, str], + session: ResilientSession, + raw: Dict[str, Any] = None, + ): Resource.__init__(self, "unknown{0}", options, session) if raw: self._parse_raw(raw) -def cls_for_resource(resource_literal): +def cls_for_resource(resource_literal: str) -> Type[Resource]: for resource in resource_class_map: if re.search(resource, resource_literal): return resource_class_map[resource] diff --git a/jira/utils/__init__.py b/jira/utils/__init__.py index c339b3ba5..86052b949 100644 --- a/jira/utils/__init__.py +++ b/jira/utils/__init__.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- """Jira utils used internally.""" import threading +from typing import Any, Optional, cast + +from requests import Response from jira.resilientsession import raise_on_error @@ -28,7 +31,7 @@ class CaseInsensitiveDict(dict): of how the header name was originally stored. If the constructor, ``.update``, or equality comparison - operations are given keys that have equal ``.lower()``s, the + operations are given keys that have equal ``.lower()`` s, the behavior is undefined. """ @@ -71,8 +74,20 @@ def threaded_requests(requests): th.join() -def json_loads(r): - raise_on_error(r) +def json_loads(r: Optional[Response]) -> Any: + """Attempts to load json the result of a response + + Args: + r (Optional[Response]): The Response object + + Raises: + JIRAError: via :py:func:`jira.resilientsession.raise_on_error` + + Returns: + Union[List[Dict[str, Any]], Dict[str, Any]]: the json + """ + raise_on_error(r) # if 'r' is None, will raise an error here + r = cast(Response, r) # tell mypy only Response-like are here try: return r.json() except ValueError: diff --git a/setup.cfg b/setup.cfg index 512262dd1..664ccb032 100644 --- a/setup.cfg +++ b/setup.cfg @@ -79,6 +79,9 @@ async = console_scripts = jirashell = jira.jirashell:main +[options.package_data] +jira = jira/py.typed + [egg_info] egg_base = . From ca9f17833403cc28da861e312898753e78b3758d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Berger?= Date: Sat, 15 May 2021 19:04:22 +0200 Subject: [PATCH 047/103] Correct lookup of issuetypes when calling create_issue (#978) * correct lookup of issuetypes when calling create_issue * Fixed syntax error for issue 978 --- jira/client.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/jira/client.py b/jira/client.py index 20aef957b..ff7cfb4d4 100644 --- a/jira/client.py +++ b/jira/client.py @@ -1375,7 +1375,7 @@ def create_issues( p = issue_data["fields"]["issuetype"] if isinstance(p, int): issue_data["fields"]["issuetype"] = {"id": p} - if isinstance(p, str) or isinstance(p, int): + if isinstance(p, str): issue_data["fields"]["issuetype"] = { "id": self.issue_type_by_name(str(p)).id } @@ -2238,12 +2238,13 @@ def issue_type_by_name(self, name: str) -> IssueType: Returns: IssueType """ - issue_types = self.issue_types() - try: - issue_type = [it for it in issue_types if it.name == name][0] - except IndexError: + matching_issue_types = [it for it in self.issue_types() if it.name == name] + if len(matching_issue_types) == 1: + return matching_issue_types[0] + elif len(matching_issue_types) == 0: raise KeyError("Issue type '%s' is unknown." % name) - return issue_type + else: + raise KeyError("Issue type '%s' appears more than once." % name) def request_types(self, service_desk: ServiceDesk) -> List[RequestType]: """Returns request types supported by a service desk instance. From 0c3e424ef1fc389f08fdf8aa9b8867eb0bfb316f Mon Sep 17 00:00:00 2001 From: Ivo Bellin Salarin Date: Sat, 15 May 2021 19:05:16 +0200 Subject: [PATCH 048/103] fix: group_members (#933) * fix: group_members Some attributes have become obsolete with the latest GDPR developments. Avoid client crashes trying to retrieve the members of a group. * chore: blacken --- jira/client.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/jira/client.py b/jira/client.py index ff7cfb4d4..ff70b2ccc 100644 --- a/jira/client.py +++ b/jira/client.py @@ -1216,11 +1216,14 @@ def group_members(self, group: str) -> OrderedDict: result = {} for user in r["users"]["items"]: - result[user["key"]] = { - "name": user["name"], - "fullname": user["displayName"], + result[user["id"]] = { + "name": user.get("name"), + "id": user.get("id"), + "accountId": user.get("accountId"), + "fullname": user.get("displayName"), "email": user.get("emailAddress", "hidden"), - "active": user["active"], + "active": user.get("active"), + "timezone": user.get("timezone"), } return OrderedDict(sorted(result.items(), key=lambda t: t[0])) From 203a3ca39c142771659a0dd523856e46b18e3505 Mon Sep 17 00:00:00 2001 From: Andrzej Klajnert Date: Mon, 17 May 2021 09:47:02 +0200 Subject: [PATCH 049/103] Improve feedback for oauth errors. (#888) --- jira/jirashell.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/jira/jirashell.py b/jira/jirashell.py index 605d97e0c..7c0eaa0b2 100644 --- a/jira/jirashell.py +++ b/jira/jirashell.py @@ -22,6 +22,7 @@ from jira import JIRA, __version__ CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".jira-python", "jirashell.ini") +SENTINEL = object() def oauth_dance(server, consumer_key, key_cert_data, print_tokens=False, verify=None): @@ -34,8 +35,16 @@ def oauth_dance(server, consumer_key, key_cert_data, print_tokens=False, verify= server + "/plugins/servlet/oauth/request-token", verify=verify, auth=oauth ) request = dict(parse_qsl(r.text)) - request_token = request["oauth_token"] - request_token_secret = request["oauth_token_secret"] + request_token = request.get("oauth_token", SENTINEL) + request_token_secret = request.get("oauth_token_secret", SENTINEL) + if request_token is SENTINEL or request_token_secret is SENTINEL: + problem = request.get("oauth_problem") + if problem is not None: + message = "OAuth error: {}".format(problem) + else: + message = " ".join(f"{key}:{value}" for key, value in request.items()) + exit(message) + if print_tokens: print("Request tokens received.") print(" Request token: {}".format(request_token)) From d1f244c244021e45a3e86e217563cb7aeca01225 Mon Sep 17 00:00:00 2001 From: studioj <22102283+studioj@users.noreply.github.com> Date: Mon, 17 May 2021 09:48:34 +0200 Subject: [PATCH 050/103] fstrings with flynt (#1044) Co-authored-by: Neefs --- jira/client.py | 102 ++++++++++++++++++--------------------- jira/config.py | 2 +- jira/exceptions.py | 38 +++++++-------- jira/jirashell.py | 28 ++++------- jira/resilientsession.py | 14 +----- jira/resources.py | 18 +++---- 6 files changed, 86 insertions(+), 116 deletions(-) diff --git a/jira/client.py b/jira/client.py index ff70b2ccc..6580d4685 100644 --- a/jira/client.py +++ b/jira/client.py @@ -186,11 +186,7 @@ def __call__(self, req): # Per Atlassian docs, use %20 for whitespace when generating qsh for URL # https://developer.atlassian.com/cloud/jira/platform/understanding-jwt/#qsh query = "&".join(sorted(parse_result.query.split("&"))).replace("+", "%20") - qsh = "%(method)s&%(path)s&%(query)s" % { - "method": req.method.upper(), - "path": path, - "query": query, - } + qsh = f"{req.method.upper()}&{path}&{query}" return hashlib.sha256(qsh.encode("utf-8")).hexdigest() @@ -477,7 +473,7 @@ def __init__( auth_method = ( oauth or basic_auth or jwt or kerberos or auth or "anonymous" ) - raise JIRAError("Can not log in with %s" % str(auth_method)) + raise JIRAError(f"Can not log in with {str(auth_method)}") self.deploymentType = None if get_server_info: @@ -558,7 +554,7 @@ def _check_for_html_error(self, content: str): # embedding the error in a huge webpage. if "" in content: self.log.warning("Got SecurityTokenMissing") - raise JIRAError("SecurityTokenMissing: %s" % content) + raise JIRAError(f"SecurityTokenMissing: {content}") return False return True @@ -893,7 +889,7 @@ def file_stream() -> MultipartEncoder: js: Union[Dict[str, Any], List[Dict[str, Any]]] = json_loads(r) if not js or not isinstance(js, Iterable): - raise JIRAError("Unable to parse JSON: %s" % js) + raise JIRAError(f"Unable to parse JSON: {js}") jira_attachment = Attachment( self._options, self._session, js[0] if isinstance(js, List) else js ) @@ -1131,7 +1127,7 @@ def update_filter( data["jql"] = jql or filter.jql data["favourite"] = favourite or filter.favourite - url = self._get_url("filter/%s" % filter_id) + url = self._get_url(f"filter/{filter_id}") r = self._session.put( url, headers={"content-type": "application/json"}, data=json.dumps(data) ) @@ -1206,7 +1202,7 @@ def group_members(self, group: str) -> OrderedDict: while end_index < size - 1: params = { "groupname": group, - "expand": "users[%s:%s]" % (end_index + 1, end_index + 50), + "expand": f"users[{end_index + 1}:{end_index + 50}]", } r2 = self._get_json("group", params=params) for user in r2["users"]["items"]: @@ -1302,7 +1298,7 @@ def create_issue( self, fields: Optional[Dict[str, Any]] = None, prefetch: bool = True, - **fieldargs + **fieldargs, ) -> Issue: """Create a new issue and return an issue Resource for it. @@ -1892,7 +1888,7 @@ def transition_issue( fields: Optional[Dict[str, Any]] = None, comment: Optional[str] = None, worklog: Optional[str] = None, - **fieldargs + **fieldargs, ): """Perform a transition on an issue. @@ -1916,7 +1912,7 @@ def transition_issue( # cannot cast to int, so try to find transitionId by name transitionId = self.find_transitionid_by_name(issue, transition) if transitionId is None: - raise JIRAError("Invalid transition name. %s" % transition) + raise JIRAError(f"Invalid transition name. {transition}") data: Dict[str, Any] = {"transition": {"id": transitionId}} if comment: @@ -1936,7 +1932,7 @@ def transition_issue( try: r_json = json_loads(r) except ValueError as e: - self.log.error("%s\n%s" % (e, r.text)) + self.log.error(f"{e}\n{r.text}") raise e return r_json @@ -2105,7 +2101,7 @@ def add_worklog( data["updateAuthor"] = data["author"] # report bug to Atlassian: author and updateAuthor parameters are # ignored. - url = self._get_url("issue/{0}/worklog".format(issue)) + url = self._get_url(f"issue/{issue}/worklog") r = self._session.post(url, params=params, data=json.dumps(data)) return Worklog(self._options, self._session, json_loads(r)) @@ -2245,9 +2241,9 @@ def issue_type_by_name(self, name: str) -> IssueType: if len(matching_issue_types) == 1: return matching_issue_types[0] elif len(matching_issue_types) == 0: - raise KeyError("Issue type '%s' is unknown." % name) + raise KeyError(f"Issue type '{name}' is unknown.") else: - raise KeyError("Issue type '%s' appears more than once." % name) + raise KeyError(f"Issue type '{name}' appears more than once.") def request_types(self, service_desk: ServiceDesk) -> List[RequestType]: """Returns request types supported by a service desk instance. @@ -2262,7 +2258,7 @@ def request_types(self, service_desk: ServiceDesk) -> List[RequestType]: service_desk = service_desk.id url = ( self.server_url - + "/rest/servicedeskapi/servicedesk/%s/requesttype" % service_desk + + f"/rest/servicedeskapi/servicedesk/{service_desk}/requesttype" ) headers = {"X-ExperimentalApi": "opt-in"} r_json = json_loads(self._session.get(url, headers=headers)) @@ -2277,7 +2273,7 @@ def request_type_by_name(self, service_desk: ServiceDesk, name: str): try: request_type = [rt for rt in request_types if rt.name == name][0] except IndexError: - raise KeyError("Request type '%s' is unknown." % name) + raise KeyError(f"Request type '{name}' is unknown.") return request_type # User permissions @@ -2572,7 +2568,7 @@ def project_role(self, project: str, id: str) -> Role: id (str): ID of the role to get """ if isinstance(id, Number): - id = "%s" % id + id = f"{id}" return self._find_for_resource(Role, (project, id)) # Resolutions @@ -3314,7 +3310,7 @@ def _get_json( try: r_json = json_loads(r) except ValueError as e: - self.log.error("%s\n%s" % (e, r.text if r else r)) + self.log.error(f"{e}\n{r.text if r else r}") raise e return r_json @@ -3401,7 +3397,7 @@ def rename_user(self, old_user: str, new_user: str): params = {"username": old_user} # raw displayName - self.log.debug("renaming %s" % self.user(old_user).emailAddress) + self.log.debug(f"renaming {self.user(old_user).emailAddress}") r = self._session.put(url, params=params, data=json.dumps(payload)) raise_on_error(r) @@ -3421,7 +3417,7 @@ def delete_user(self, username: str) -> bool: """ - url = self._get_latest_url("user/?username=%s" % username) + url = self._get_latest_url(f"user/?username={username}") r = self._session.delete(url) if 200 <= r.status_code <= 299: @@ -3449,9 +3445,10 @@ def deactivate_user(self, username: str) -> Union[str, int]: user.raw["session"]["name"], user.raw["session"]["value"], ) - url = self._options[ - "server" - ] + "/admin/rest/um/1/user/deactivate?username=%s" % (username) + url = ( + self._options["server"] + + f"/admin/rest/um/1/user/deactivate?username={username}" + ) # We can't use our existing session here - this endpoint is fragile and objects to extra headers try: r = requests.post( @@ -3467,13 +3464,12 @@ def deactivate_user(self, username: str) -> Union[str, int]: return True else: self.log.warning( - "Got response from deactivating %s: %s" - % (username, r.status_code) + f"Got response from deactivating {username}: {r.status_code}" ) return r.status_code except Exception as e: - self.log.error("Error Deactivating %s: %s" % (username, e)) - raise JIRAError("Error Deactivating %s: %s" % (username, e)) + self.log.error(f"Error Deactivating {username}: {e}") + raise JIRAError(f"Error Deactivating {username}: {e}") else: url = self.server_url + "/secure/admin/user/EditUser.jspa" self._options["headers"][ @@ -3496,13 +3492,12 @@ def deactivate_user(self, username: str) -> Union[str, int]: return True else: self.log.warning( - "Got response from deactivating %s: %s" - % (username, r.status_code) + f"Got response from deactivating {username}: {r.status_code}" ) return r.status_code except Exception as e: - self.log.error("Error Deactivating %s: %s" % (username, e)) - raise JIRAError("Error Deactivating %s: %s" % (username, e)) + self.log.error(f"Error Deactivating {username}: {e}") + raise JIRAError(f"Error Deactivating {username}: {e}") def reindex(self, force: bool = False, background: bool = True) -> bool: """Start jira re-indexing. Returns True if reindexing is in progress or not needed, or False. @@ -3567,7 +3562,7 @@ def backup(self, filename: str = "backup.zip", attachments: bool = False): if r.status_code == 200: return True else: - self.log.warning("Got %s response from calling backup." % r.status_code) + self.log.warning(f"Got {r.status_code} response from calling backup.") return r.status_code except Exception as e: self.log.error("I see %s", e) @@ -3625,7 +3620,7 @@ def backup_download(self, filename: str = None): local_file = filename or remote_file url = self.server_url + "/webdav/backupmanager/" + remote_file try: - self.log.debug("Writing file to %s" % local_file) + self.log.debug(f"Writing file to {local_file}") with open(local_file, "wb") as file: try: resp = self._session.get( @@ -3634,12 +3629,12 @@ def backup_download(self, filename: str = None): except Exception: raise JIRAError() if not resp.ok: - self.log.error("Something went wrong with download: %s" % resp.text) + self.log.error(f"Something went wrong with download: {resp.text}") raise JIRAError(resp.text) for block in resp.iter_content(1024): file.write(block) except JIRAError as je: - self.log.error("Unable to access remote backup file: %s" % je) + self.log.error(f"Unable to access remote backup file: {je}") except IOError as ioe: self.log.error(ioe) return None @@ -3678,7 +3673,7 @@ def delete_project(self, pid: Union[str, Project]) -> Optional[bool]: if isinstance(pid, Project) and hasattr(pid, "id"): pid = str(pid.id) - url = self._get_url("project/%s" % pid) + url = self._get_url(f"project/{pid}") r = self._session.delete(url) if r.status_code == 403: raise JIRAError("Not enough permissions to delete project") @@ -3757,7 +3752,7 @@ def projectcategories(self): @lru_cache(maxsize=None) def avatars(self, entity="project"): - url = self._get_url("avatar/%s/system" % entity) + url = self._get_url(f"avatar/{entity}/system") r = self._session.get(url) data: Dict[str, Any] = json_loads(r) @@ -3802,7 +3797,7 @@ def workflows(self): def delete_screen(self, id: str): - url = self._get_url("screens/%s" % id) + url = self._get_url(f"screens/{id}") r = self._session.delete(url) data = json_loads(r) @@ -3812,7 +3807,7 @@ def delete_screen(self, id: str): def delete_permissionscheme(self, id: str): - url = self._get_url("permissionscheme/%s" % id) + url = self._get_url(f"permissionscheme/{id}") r = self._session.delete(url) data = json_loads(r) @@ -4103,7 +4098,7 @@ def role(self) -> List[Dict[str, Any]]: def get_igrid(self, issueid: str, customfield: str, schemeid: str): url = self.server_url + "/rest/idalko-igrid/1.0/datagrid/data" if str(customfield).isdigit(): - customfield = "customfield_%s" % customfield + customfield = f"customfield_{customfield}" params = { "_issueId": issueid, "_fieldId": customfield, @@ -4246,7 +4241,7 @@ def sprints( return self._fetch_pages( Sprint, "values", - "board/%s/sprint" % board_id, + f"board/{board_id}/sprint", startAt, maxResults, params, @@ -4280,7 +4275,7 @@ def update_sprint(self, id, name=None, startDate=None, endDate=None, state=None) ) payload["state"] = state - url = self._get_url("sprint/%s" % id, base=self.AGILE_BASE_URL) + url = self._get_url(f"sprint/{id}", base=self.AGILE_BASE_URL) r = self._session.put(url, data=json.dumps(payload)) return json_loads(r) @@ -4288,8 +4283,7 @@ def update_sprint(self, id, name=None, startDate=None, endDate=None, state=None) def incompletedIssuesEstimateSum(self, board_id: str, sprint_id: str): """Return the total incompleted points this sprint.""" data: Dict[str, Any] = self._get_json( - "rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s" - % (board_id, sprint_id), + f"rapid/charts/sprintreport?rapidViewId={board_id}&sprintId={sprint_id}", base=self.AGILE_BASE_URL, ) return data["contents"]["incompletedIssuesEstimateSum"]["value"] @@ -4297,8 +4291,7 @@ def incompletedIssuesEstimateSum(self, board_id: str, sprint_id: str): def removed_issues(self, board_id: str, sprint_id: str): """Return the completed issues for the sprint.""" r_json: Dict[str, Any] = self._get_json( - "rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s" - % (board_id, sprint_id), + f"rapid/charts/sprintreport?rapidViewId={board_id}&sprintId={sprint_id}", base=self.AGILE_BASE_URL, ) issues = [ @@ -4311,8 +4304,7 @@ def removed_issues(self, board_id: str, sprint_id: str): def removedIssuesEstimateSum(self, board_id: str, sprint_id: str): """Return the total incompleted points this sprint.""" data: Dict[str, Any] = self._get_json( - "rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s" - % (board_id, sprint_id), + f"rapid/charts/sprintreport?rapidViewId={board_id}&sprintId={sprint_id}", base=self.AGILE_BASE_URL, ) return data["contents"]["puntedIssuesEstimateSum"]["value"] @@ -4428,7 +4420,7 @@ def create_sprint( self._options["agile_rest_path"] == GreenHopperResource.GREENHOPPER_REST_PATH ): - url = self._get_url("sprint/%s" % board_id, base=self.AGILE_BASE_URL) + url = self._get_url(f"sprint/{board_id}", base=self.AGILE_BASE_URL) r = self._session.post(url) raw_issue_json = json_loads(r) """ now r contains something like: @@ -4444,7 +4436,7 @@ def create_sprint( }""" url = self._get_url( - "sprint/%s" % raw_issue_json["id"], base=self.AGILE_BASE_URL + f"sprint/{raw_issue_json['id']}", base=self.AGILE_BASE_URL ) r = self._session.put(url, data=json.dumps(payload)) raw_issue_json = json_loads(r) @@ -4477,7 +4469,7 @@ def add_issues_to_sprint(self, sprint_id: int, issue_keys: List[str]) -> Respons Response """ if self._options["agile_rest_path"] == GreenHopperResource.AGILE_BASE_REST_PATH: - url = self._get_url("sprint/%s/issue" % sprint_id, base=self.AGILE_BASE_URL) + url = self._get_url(f"sprint/{sprint_id}/issue", base=self.AGILE_BASE_URL) payload = {"issues": issue_keys} try: return self._session.post(url, data=json.dumps(payload)) @@ -4535,7 +4527,7 @@ def add_issues_to_epic( data: Dict[str, Any] = {} data["issueKeys"] = issue_keys data["ignoreEpics"] = ignore_epics - url = self._get_url("epics/%s/add" % epic_id, base=self.AGILE_BASE_URL) + url = self._get_url(f"epics/{epic_id}/add", base=self.AGILE_BASE_URL) return self._session.put(url, data=json.dumps(data)) # TODO(ssbarnea): Both GreenHopper and new Jira Agile API support moving more than one issue. diff --git a/jira/config.py b/jira/config.py index 10f666d13..9d54f7e88 100644 --- a/jira/config.py +++ b/jira/config.py @@ -87,7 +87,7 @@ def findfile(path): config_file = findfile("config.ini") if config_file: - logging.debug("Found %s config file" % config_file) + logging.debug(f"Found {config_file} config file") if not profile: if config_file: diff --git a/jira/exceptions.py b/jira/exceptions.py index 1f52bd3d9..dddcc0787 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -19,7 +19,7 @@ def __init__( url: str = None, request: Response = None, response: Response = None, - **kwargs + **kwargs, ): """Creates a JIRAError. @@ -41,43 +41,39 @@ def __init__( self.ci_run = "GITHUB_ACTION" in os.environ def __str__(self) -> str: - """Return a string representation of the error. - - Returns: - str - """ - t = "JiraError HTTP %s" % self.status_code + t = f"JiraError HTTP {self.status_code}" if self.url: - t += " url: %s" % self.url + t += f" url: {self.url}" details = "" - if self.request is not None and hasattr(self.request, "headers"): - details += "\n\trequest headers = %s" % self.request.headers - - if self.request is not None and hasattr(self.request, "text"): - details += "\n\trequest text = %s" % self.request.text + if self.request is not None: + if hasattr(self.request, "headers"): + details += f"\n\trequest headers = {self.request.headers}" - if self.response is not None and hasattr(self.response, "headers"): - details += "\n\tresponse headers = %s" % self.response.headers + if hasattr(self.request, "text"): + details += f"\n\trequest text = {self.request.text}" + if self.response is not None: + if hasattr(self.response, "headers"): + details += f"\n\tresponse headers = {self.response.headers}" - if self.response is not None and hasattr(self.response, "text"): - details += "\n\tresponse text = %s" % self.response.text + if hasattr(self.response, "text"): + details += f"\n\tresponse text = {self.response.text}" # separate logging for CI makes sense. if self.ci_run: if self.text: - t += "\n\ttext: %s" % self.text + t += f"\n\ttext: {self.text}" t += details # Only log to tempfile if the option is set. elif self.log_to_tempfile: fd, file_name = tempfile.mkstemp(suffix=".tmp", prefix="jiraerror-") with open(file_name, "w") as f: - t += " details: %s" % file_name + t += f" details: {file_name}" f.write(details) # Otherwise, just return the error as usual else: if self.text: - t += "\n\ttext: %s" % self.text - t += "\n\t" + details + t += f"\n\ttext: {self.text}" + t += f"\n\t{details}" return t diff --git a/jira/jirashell.py b/jira/jirashell.py index 7c0eaa0b2..5ce5d1710 100644 --- a/jira/jirashell.py +++ b/jira/jirashell.py @@ -47,19 +47,13 @@ def oauth_dance(server, consumer_key, key_cert_data, print_tokens=False, verify= if print_tokens: print("Request tokens received.") - print(" Request token: {}".format(request_token)) - print(" Request token secret: {}".format(request_token_secret)) + print(f" Request token: {request_token}") + print(f" Request token secret: {request_token_secret}") # step 2: prompt user to validate - auth_url = "{}/plugins/servlet/oauth/authorize?oauth_token={}".format( - server, request_token - ) + auth_url = f"{server}/plugins/servlet/oauth/authorize?oauth_token={request_token}" if print_tokens: - print( - "Please visit this URL to authorize the OAuth request:\n\t{}".format( - auth_url - ) - ) + print(f"Please visit this URL to authorize the OAuth request:\n\t{auth_url}") else: webbrowser.open_new(auth_url) print( @@ -67,9 +61,7 @@ def oauth_dance(server, consumer_key, key_cert_data, print_tokens=False, verify= ) approved = input( - "Have you authorized this program to connect on your behalf to {}? (y/n)".format( - server - ) + f"Have you authorized this program to connect on your behalf to {server}? (y/n)" ) if approved.lower() != "y": @@ -92,8 +84,8 @@ def oauth_dance(server, consumer_key, key_cert_data, print_tokens=False, verify= if print_tokens: print("Access tokens received.") - print(" Access token: {}".format(access["oauth_token"])) - print(" Access token secret: {}".format(access["oauth_token_secret"])) + print(f" Access token: {access['oauth_token']}") + print(f" Access token secret: {access['oauth_token_secret']}") return { "access_token": access["oauth_token"], @@ -111,7 +103,7 @@ def process_config(): try: parser.read(CONFIG_PATH) except configparser.ParsingError as err: - print("Couldn't read config file at path: {}\n{}".format(CONFIG_PATH, err)) + print(f"Couldn't read config file at path: {CONFIG_PATH}\n{err}") raise if parser.has_section("options"): @@ -313,7 +305,7 @@ def handle_basic_auth(auth, server): print("Getting password from keyring...") password = keyring.get_password(server, auth["username"]) assert password, "No password provided!" - return (auth["username"], password) + return auth["username"], password def main(): @@ -371,7 +363,7 @@ def main(): ip_shell = InteractiveShellEmbed( banner1="" ) - ip_shell("*** Jira shell active; client is in 'jira'." " Press Ctrl-D to exit.") + ip_shell("*** Jira shell active; client is in 'jira'. Press Ctrl-D to exit.") except Exception as e: print(e, file=sys.stderr) return 2 diff --git a/jira/resilientsession.py b/jira/resilientsession.py index 2fa48de6d..baa19904b 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -110,15 +110,7 @@ def __recoverable( msg = str(response) if isinstance(response, ConnectionError): logging.warning( - "Got ConnectionError [%s] errno:%s on %s %s\n%s\n%s" - % ( - response, - response.errno, - request, - url, - vars(response), - response.__dict__, - ) + f"Got ConnectionError [{response}] errno:{response.errno} on {request} {url}\n{vars(response)}\n{response.__dict__}" ) if isinstance(response, Response): if response.status_code in [502, 503, 504, 401]: @@ -174,9 +166,7 @@ def __verb( if response.status_code >= 200 and response.status_code <= 299: return response except ConnectionError as e: - logging.warning( - "%s while doing %s %s [%s]" % (e, verb.upper(), url, kwargs) - ) + logging.warning(f"{e} while doing {verb.upper()} {url} [{kwargs}]") exception = e retry_number += 1 diff --git a/jira/resources.py b/jira/resources.py index 29b2fabb3..604b116d9 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -177,8 +177,8 @@ def __repr__(self) -> str: if name in self.raw: names.append(name + "=" + repr(self.raw[name])) if not names: - return "" % (self.__class__.__name__, id(self)) - return "" % (self.__class__.__name__, ", ".join(names)) + return f"" + return f"" def __getattr__(self, item: str): """Allow access of attributes via names. @@ -211,7 +211,7 @@ def __getattr__(self, item: str): return self.raw[item] else: raise AttributeError( - "%r object has no attribute %r (%s)" % (self.__class__, item, e) + f"{self.__class__!r} object has no attribute {item!r} ({e})" ) # def __getstate__(self): @@ -269,7 +269,7 @@ def update( async_: Optional[bool] = None, jira: "JIRA" = None, notify: bool = True, - **kwargs: Any + **kwargs: Any, ): """Update this resource on the server. @@ -422,7 +422,7 @@ def _load( try: j = json_loads(r) except ValueError as e: - logging.error("%s:\n%s" % (e, r.text)) + logging.error(f"{e}:\n{r.text}") raise e if path: j = j[path] @@ -436,7 +436,7 @@ def _parse_raw(self, raw: Dict[str, Any]): """ self.raw = raw if not raw: - raise NotImplementedError("We cannot instantiate empty resources: %s" % raw) + raise NotImplementedError(f"We cannot instantiate empty resources: {raw}") dict2resource(raw, self, self._options, self._session) def _default_headers(self, user_headers): @@ -581,7 +581,7 @@ def update( # type: ignore[override] # incompatible supertype ignored async_: bool = None, jira: "JIRA" = None, notify: bool = True, - **fieldargs + **fieldargs, ): """Update this issue on the server. @@ -662,7 +662,7 @@ def permalink(self): Returns: str: URL of the issue """ - return "%s/browse/%s" % (self._options["server"], self.key) + return f"{self._options['server']}/browse/{self.key}" def __eq__(self, other): """Comparison method.""" @@ -1162,7 +1162,7 @@ def find(self, id, params=None): Resource.find(self, id, params) else: # Old, private GreenHopper API had non-standard way of loading Sprint - url = self._get_url("sprint/%s/edit/model" % id) + url = self._get_url(f"sprint/{id}/edit/model") self._load(url, params=params, path="sprint") From b360dcc5806ae79d753ce05110b436afe8f1e942 Mon Sep 17 00:00:00 2001 From: Julen <12843626+julenpardo@users.noreply.github.com> Date: Tue, 18 May 2021 13:12:00 +0200 Subject: [PATCH 051/103] Make user search GDPR compliant (#927) * Make user search GDPR compliant The `username` field is deprecated and Jira is gradually removing it from the cloud instances. This is the second time such changes break our integrations; until now, our workaround consisted of first searching for the user based on the email, and then use the account id from the response for the other requests. But now we cannot search anymore for users based on the email, and we need to use the `query` field. Even if we just pass the same exact value we passed to `username`. I can imagine there're several places in the code that would require of being changed for being completely GDPR compliant, but I have no time at the moment to fix all of them :( Thanks for your work! * Remove redundant check The payload object was already initialized with the `username` value, so there's no need to check whether is specified or not. * Init payload iwth both username and query `query` is a different parameter from `username` so it shouldn't replace it actually. Tested with v3 API and it's working. * Update docblock regarding Jira cloud parameters Co-authored-by: adehad <26027314+adehad@users.noreply.github.com> * Fix optional `user` param * Update docblock Co-authored-by: adehad <26027314+adehad@users.noreply.github.com> * Update docblock Co-authored-by: adehad <26027314+adehad@users.noreply.github.com> * Update docblock Co-authored-by: adehad <26027314+adehad@users.noreply.github.com> Co-authored-by: adehad <26027314+adehad@users.noreply.github.com> --- jira/client.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/jira/client.py b/jira/client.py index 6580d4685..fd5b5a592 100644 --- a/jira/client.py +++ b/jira/client.py @@ -2959,30 +2959,39 @@ def delete_user_avatar(self, username: str, avatar: str): def search_users( self, - user: str, + user: Optional[str] = None, startAt: int = 0, maxResults: int = 50, includeActive: bool = True, includeInactive: bool = False, + query: Optional[str] = None, ) -> ResultList[User]: """Get a list of user Resources that match the specified search string. + "username" query parameter is deprecated in Jira Cloud; the expected parameter now is "query", which can just be the full + email again. But the "user" parameter is kept for backwards compatibility, i.e. Jira Server/Data Center. Args: - user (str): a string to match usernames, name or email against. + user (Optional[str]): a string to match usernames, name or email against. startAt (int): index of the first user to return. maxResults (int): maximum number of users to return. If maxResults evaluates as False, it will try to get all items in batches. includeActive (bool): If true, then active users are included in the results. (Default: True) includeInactive (bool): If true, then inactive users are included in the results. (Default: False) + query (Optional[str]): Search term. It can just be the email. Returns: ResultList[User] """ + if not user and not query: + raise ValueError("Either 'user' or 'query' arguments must be specified.") + params = { "username": user, + "query": query, "includeActive": includeActive, "includeInactive": includeInactive, } + return self._fetch_pages(User, None, "user/search", startAt, maxResults, params) def search_allowed_users_for_issue( From 760d584ea84ed5cdc4dd690f3f1ef8600c9d1a21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 May 2021 12:12:28 +0100 Subject: [PATCH 052/103] Bump pytest-cov from 2.11.1 to 2.12.0 (#1046) Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.11.1 to 2.12.0. - [Release notes](https://github.com/pytest-dev/pytest-cov/releases) - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.11.1...v2.12.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- test-requirements.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test-requirements.txt b/test-requirements.txt index 5f26a0ad2..80a0c7c10 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -16,7 +16,7 @@ cfgv==3.2.0 # via pre-commit chardet==4.0.0 # via requests -coverage==5.5 +coverage[toml]==5.5 # via pytest-cov distlib==0.3.1 # via virtualenv @@ -63,7 +63,7 @@ pyparsing==2.4.7 # via packaging pytest-cache==1.0 # via -r test-requirements.in -pytest-cov==2.11.1 +pytest-cov==2.12.0 # via -r test-requirements.in pytest-forked==1.3.0 # via pytest-xdist @@ -108,6 +108,7 @@ termcolor==1.1.0 # via pytest-sugar toml==0.10.2 # via + # coverage # pre-commit # pytest typed-ast==1.4.3 From 445d37591b08dee45f634e9d0da706bb1d4ac8ad Mon Sep 17 00:00:00 2001 From: Yen3 Date: Tue, 18 May 2021 13:13:03 +0200 Subject: [PATCH 053/103] Close the file descriptor for add_attachment (#957) * Close the file descriptor for add_attachment If the attachment is string, the add_attachment function creates a file descriptor then forget to close it. The patch closes the file descriptor after the post action. * Pass a correct type for the variable * Convert type earliy * Refine code format --- jira/client.py | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/jira/client.py b/jira/client.py index fd5b5a592..ff7961834 100644 --- a/jira/client.py +++ b/jira/client.py @@ -35,6 +35,7 @@ Type, TypeVar, Union, + cast, no_type_check, ) from urllib.parse import urlparse @@ -845,8 +846,11 @@ def add_attachment( Returns: Attachment """ + close_attachment = False if isinstance(attachment, str): attachment: BufferedReader = open(attachment, "rb") # type: ignore + attachment = cast(BufferedReader, attachment) + close_attachment = True elif isinstance(attachment, BufferedReader) and attachment.mode != "rb": self.log.warning( "%s was not opened in 'rb' mode, attaching file may fail." @@ -861,13 +865,17 @@ def add_attachment( if "MultipartEncoder" not in globals(): method = "old" - r = self._session.post( - url, - files={"file": (fname, attachment, "application/octet-stream")}, - headers=CaseInsensitiveDict( - {"content-type": None, "X-Atlassian-Token": "no-check"} - ), - ) + try: + r = self._session.post( + url, + files={"file": (fname, attachment, "application/octet-stream")}, + headers=CaseInsensitiveDict( + {"content-type": None, "X-Atlassian-Token": "no-check"} + ), + ) + finally: + if close_attachment: + attachment.close() else: method = "MultipartEncoder" @@ -878,14 +886,21 @@ def file_stream() -> MultipartEncoder: ) m = file_stream() - r = self._session.post( - url, - data=m, - headers=CaseInsensitiveDict( - {"content-type": m.content_type, "X-Atlassian-Token": "no-check"} - ), - retry_data=file_stream, - ) + try: + r = self._session.post( + url, + data=m, + headers=CaseInsensitiveDict( + { + "content-type": m.content_type, + "X-Atlassian-Token": "no-check", + } + ), + retry_data=file_stream, + ) + finally: + if close_attachment: + attachment.close() js: Union[Dict[str, Any], List[Dict[str, Any]]] = json_loads(r) if not js or not isinstance(js, Iterable): From de73cfde2d410cfd9bd24b9e7c4eda0f6152a9e0 Mon Sep 17 00:00:00 2001 From: Tom Lianza Date: Tue, 18 May 2021 04:14:55 -0700 Subject: [PATCH 054/103] Fixes #404 - Allow you to set the reporter as well, not just the assignee. (#987) Co-authored-by: Tom Lianza --- jira/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jira/resources.py b/jira/resources.py index 604b116d9..d374503da 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -619,7 +619,7 @@ def update( # type: ignore[override] # incompatible supertype ignored # apply some heuristics to make certain changes easier if isinstance(value, str): if field == "assignee" or field == "reporter": - fields_dict["assignee"] = {"name": value} + fields_dict[field] = {"name": value} elif field == "comment": if "comment" not in update_dict: update_dict["comment"] = [] From 630973fa5d16abe3ba69ae57dc1e5ec7d8afe3e2 Mon Sep 17 00:00:00 2001 From: Matt Keenan <67590045+matthewkeenan@users.noreply.github.com> Date: Tue, 18 May 2021 14:07:01 +0100 Subject: [PATCH 055/103] Allow expands for retrieval of comments (#1003) * Fix: #949 Allow expands for retrieval of comments * Move comment deletion before assert call * Black formating * Update tests.py * Update comment * Update client.py * Update client.py * Update client.py * Add type and return information * Update client.py for black style Co-authored-by: Sorin Sbarnea --- jira/client.py | 30 +++++++++++++++++++----------- tests/tests.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/jira/client.py b/jira/client.py index ff7961834..f785fac6d 100644 --- a/jira/client.py +++ b/jira/client.py @@ -1629,15 +1629,20 @@ def assign_issue(self, issue: Union[int, str], assignee: str) -> bool: return True @translate_resource_args - def comments(self, issue: str) -> List[Comment]: + def comments(self, issue: str, expand: Optional[str] = None) -> List[Comment]: """Get a list of comment Resources. - Args: - issue (str): the issue to get comments from - Returns: - List[Comment] + :param issue: the issue to get comments from + :type issue: str + :param expand: extra information to fetch for each comment + such as renderedBody and properties. + :type expand: str + :rtype: List[Comment] """ - r_json = self._get_json("issue/{}/comment".format(str(issue))) + params = {} + if expand is not None: + params["expand"] = expand + r_json = self._get_json("issue/{}/comment".format(str(issue)), params=params) comments = [ Comment(self._options, self._session, raw_comment_json) @@ -1646,14 +1651,17 @@ def comments(self, issue: str) -> List[Comment]: return comments @translate_resource_args - def comment(self, issue: str, comment: str) -> Comment: + def comment( + self, issue: str, comment: str, expand: Optional[str] = None + ) -> Comment: """Get a comment Resource from the server for the specified ID. - Args: - issue (str): ID or key of the issue to get the comment from - comment (str): ID of the comment to get + :param issue: ID or key of the issue to get the comment from + :param comment: ID of the comment to get + :param expand: extra information to fetch for comment + such as renderedBody and properties. """ - return self._find_for_resource(Comment, (issue, comment)) + return self._find_for_resource(Comment, (issue, comment), expand=expand) @translate_resource_args def add_comment( diff --git a/tests/tests.py b/tests/tests.py index c89ee422b..71fe1a619 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -1066,6 +1066,22 @@ def test_comments(self): comments = self.jira.comments(issue) assert len(comments) == 0 + def test_expanded_comments(self): + comment1 = self.jira.add_comment(self.issue_1, "First comment") + comment2 = self.jira.add_comment(self.issue_1, "Second comment") + comments = self.jira.comments(self.issue_1, expand="renderedBody") + self.assertTrue(hasattr(comments[0], "renderedBody")) + ret_comment1 = self.jira.comment( + self.issue_1, comment1.id, expand="renderedBody" + ) + ret_comment2 = self.jira.comment(self.issue_1, comment2.id) + comment1.delete() + comment2.delete() + self.assertTrue(hasattr(ret_comment1, "renderedBody")) + self.assertFalse(hasattr(ret_comment2, "renderedBody")) + comments = self.jira.comments(self.issue_1) + assert len(comments) == 0 + def test_add_comment(self): comment = self.jira.add_comment( self.issue_3, From 2c718f1130de0d1c274a778e9f053c01e8bbe50a Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Mon, 24 May 2021 08:36:45 +0100 Subject: [PATCH 056/103] fix assign issue behaviour and correct watcher functions docstrings (#1053) * bugfix: fix assign issue * bugfix: watcher function docstrings and logic corrected * feat: use _is_cloud property to handle Cloud vs self-hosted differences * feat: convert more cloud checks to use _is_cloud --- jira/client.py | 74 +++++++++++++++++++++++++++++++---------- make_local_jira_user.py | 5 +-- tests/tests.py | 16 +++++---- tox.ini | 1 + 4 files changed, 70 insertions(+), 26 deletions(-) diff --git a/jira/client.py b/jira/client.py index f785fac6d..c51119245 100644 --- a/jira/client.py +++ b/jira/client.py @@ -504,6 +504,11 @@ def server_url(self) -> str: """Return the server url""" return str(self._options["server"]) + @property + def _is_cloud(self) -> bool: + """Return whether we are on a Cloud based Jira instance.""" + return self.deploymentType in ("Cloud",) + def _create_cookie_auth( self, auth: Tuple[str, str], @@ -1601,13 +1606,47 @@ def createmeta( params["expand"] = expand return self._get_json("issue/createmeta", params) - def _get_user_key(self, user: str) -> str: - """Internal method for translating an user (str) to an key.""" + def _get_user_identifier(self, user: User) -> str: + """Get the unique identifier depending on the deployment type. + + - Cloud: 'accountId' + - Self Hosted: 'name' (equivalent to username) + + Args: + user (User): a User object + + Returns: + str: the User's unique identifier. + """ + return user.accountId if self._is_cloud else user.name + + def _get_user_id(self, user: str) -> str: + """Internal method for translating an user search (str) to an id. + + This function uses :py:meth:`JIRA.search_users` to find the user + and then using :py:meth:`JIRA._get_user_identifier` extracts + the relevant identifier property depending on whether + the instance is a Cloud or self-hosted Instance. + + + Args: + user (str): The search term used for finding a user. + + Raises: + JIRAError: If any error occurs. + + Returns: + str: The Jira user's identifier. + """ try: - key = self.search_users(user, maxResults=1)[0].key + user_obj: User + if self._is_cloud: + user_obj = self.search_users(query=user, maxResults=1)[0] + else: + user_obj = self.search_users(user=user, maxResults=1)[0] except Exception as e: raise JIRAError(str(e)) - return key + return self._get_user_identifier(user_obj) # non-resource @translate_resource_args @@ -1622,8 +1661,8 @@ def assign_issue(self, issue: Union[int, str], assignee: str) -> bool: bool """ url = self._get_latest_url("issue/{}/assignee".format(str(issue))) - payload = {"name": self._get_user_key(assignee)} - # 'key' and 'name' are deprecated in favor of accountId + user_id = self._get_user_id(assignee) + payload = {"accountId": user_id} if self._is_cloud else {"name": user_id} r = self._session.put(url, data=json.dumps(payload)) raise_on_error(r) return True @@ -2010,7 +2049,7 @@ def add_watcher(self, issue: str, watcher: str) -> Response: Args: issue (str): ID or key of the issue affected - watcher (str): key of the user to add to the watchers list + watcher (str): name of the user to add to the watchers list """ url = self._get_url("issue/" + str(issue) + "/watchers") return self._session.post(url, data=json.dumps(watcher)) @@ -2021,15 +2060,16 @@ def remove_watcher(self, issue: str, watcher: str) -> Response: Args: issue (str): ID or key of the issue affected - watcher (str): key of the user to remove from the watchers list + watcher (str): name of the user to remove from the watchers list Returns: Response """ url = self._get_url("issue/" + str(issue) + "/watchers") # https://docs.atlassian.com/software/jira/docs/api/REST/8.13.6/#api/2/issue-removeWatcher - params = {"username": watcher} - result = self._session.delete(url, params=params) + user_id = self._get_user_id(watcher) + payload = {"accountId": user_id} if self._is_cloud else {"username": user_id} + result = self._session.delete(url, params=payload) return result @translate_resource_args @@ -3186,7 +3226,7 @@ def kill_websudo(self) -> Optional[Response]: Returns: Optional[Response] """ - if self.deploymentType != "Cloud": + if not self._is_cloud: url = self.server_url + "/rest/auth/1/websudo" return self._session.delete(url) return None @@ -3467,7 +3507,7 @@ def deactivate_user(self, username: str) -> Union[str, int]: Returns: Union[str, int] """ - if self.deploymentType == "Cloud": + if self._is_cloud: # Disabling users now needs cookie auth in the Cloud - see https://jira.atlassian.com/browse/ID-6230 if "authCookie" not in vars(self): user = self.session() @@ -3582,7 +3622,7 @@ def reindex(self, force: bool = False, background: bool = True) -> bool: def backup(self, filename: str = "backup.zip", attachments: bool = False): """Will call jira export to backup as zipped xml. Returning with success does not mean that the backup process finished.""" payload: Any # _session.post is pretty open - if self.deploymentType == "Cloud": + if self._is_cloud: url = self.server_url + "/rest/backup/1/export/runbackup" payload = json.dumps({"cbAttachments": attachments}) self._options["headers"]["X-Requested-With"] = "XMLHttpRequest" @@ -3605,7 +3645,7 @@ def backup_progress(self): Is there a way to get progress for Server version? """ epoch_time = int(time.time() * 1000) - if self.deploymentType == "Cloud": + if self._is_cloud: url = self.server_url + "/rest/obm/1.0/getprogress?_=%i" % epoch_time else: self.log.warning("This functionality is not available in Server version") @@ -3632,7 +3672,7 @@ def backup_progress(self): def backup_complete(self) -> Optional[bool]: """Return boolean based on 'alternativePercentage' and 'size' returned from backup_progress (cloud only).""" - if self.deploymentType != "Cloud": + if not self._is_cloud: self.log.warning("This functionality is not available in Server version") return None status = self.backup_progress() @@ -3645,7 +3685,7 @@ def backup_complete(self) -> Optional[bool]: def backup_download(self, filename: str = None): """Download backup file from WebDAV (cloud only).""" - if self.deploymentType != "Cloud": + if not self._is_cloud: self.log.warning("This functionality is not available in Server version") return None remote_file = self.backup_progress()["fileName"] @@ -4414,7 +4454,7 @@ def create_board( project_ids = project_ids.split(",") # type: ignore # re-use of variable payload["projectIds"] = project_ids payload["preset"] = preset - if self.deploymentType == "Cloud": + if self._is_cloud: payload["locationType"] = location_type payload["locationId"] = location_id url = self._get_url("rapidview/create/presets", base=self.AGILE_BASE_URL) diff --git a/make_local_jira_user.py b/make_local_jira_user.py index 984c7c77b..5c9d8897b 100644 --- a/make_local_jira_user.py +++ b/make_local_jira_user.py @@ -17,8 +17,9 @@ def add_user_to_jira(): CI_JIRA_URL, basic_auth=(environ["CI_JIRA_ADMIN"], environ["CI_JIRA_ADMIN_PASSWORD"]), ).add_user( - environ["CI_JIRA_USER"], - "user@example.com", + username=environ["CI_JIRA_USER"], + email="user@example.com", + fullname=environ["CI_JIRA_USER_FULL_NAME"], password=environ["CI_JIRA_USER_PASSWORD"], ) print("user {}".format(environ["CI_JIRA_USER"])) diff --git a/tests/tests.py b/tests/tests.py index 71fe1a619..50701e781 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -303,6 +303,7 @@ def __init__(self): py.test.exit("FATAL: WTF!?") self.user_admin = self.jira_admin.search_users(self.CI_JIRA_ADMIN)[0] + self.user_normal = self.jira_admin.search_users(self.CI_JIRA_USER)[0] self.initialized = 1 @@ -667,6 +668,7 @@ def setUp(self): self.jira = JiraTestManager().jira_admin self.jira_normal = JiraTestManager().jira_normal self.user_admin = self.jira.search_users(self.test_manager.CI_JIRA_ADMIN)[0] + self.user_normal = self.test_manager.user_normal self.project_b = self.test_manager.project_b self.project_a = self.test_manager.project_a self.issue_1 = self.test_manager.project_b_issue1 @@ -1037,17 +1039,17 @@ def test_createmeta_expand(self): self.assertTrue("fields" in meta["projects"][0]["issuetypes"][0]) def test_assign_issue(self): - self.assertTrue(self.jira.assign_issue(self.issue_1, self.user_admin.name)) + self.assertTrue(self.jira.assign_issue(self.issue_1, self.user_normal.name)) self.assertEqual( - self.jira.issue(self.issue_1).fields.assignee.name, self.user_admin.name + self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name ) def test_assign_issue_with_issue_obj(self): issue = self.jira.issue(self.issue_1) - x = self.jira.assign_issue(issue, self.user_admin.name) + x = self.jira.assign_issue(issue, self.user_normal.name) self.assertTrue(x) self.assertEqual( - self.jira.issue(self.issue_1).fields.assignee.name, self.user_admin.name + self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name ) def test_assign_to_bad_issue_raises(self): @@ -1289,15 +1291,15 @@ def test_votes_with_issue_obj(self): def test_add_remove_watcher(self): # removing it in case it exists, so we know its state - self.jira.remove_watcher(self.issue_1, self.test_manager.user_admin.key) + self.jira.remove_watcher(self.issue_1, self.test_manager.user_normal.name) init_watchers = self.jira.watchers(self.issue_1).watchCount # adding a new watcher - self.jira.add_watcher(self.issue_1, self.test_manager.user_admin.key) + self.jira.add_watcher(self.issue_1, self.test_manager.user_normal.name) self.assertEqual(self.jira.watchers(self.issue_1).watchCount, init_watchers + 1) # now we verify that remove does indeed remove watchers - self.jira.remove_watcher(self.issue_1, self.test_manager.user_admin.key) + self.jira.remove_watcher(self.issue_1, self.test_manager.user_normal.name) new_watchers = self.jira.watchers(self.issue_1).watchCount self.assertEqual(init_watchers, new_watchers) diff --git a/tox.ini b/tox.ini index 88078dadf..c91a9d648 100644 --- a/tox.ini +++ b/tox.ini @@ -44,6 +44,7 @@ setenv = CI_JIRA_ADMIN=admin CI_JIRA_ADMIN_PASSWORD=admin CI_JIRA_USER=jira_user + CI_JIRA_USER_FULL_NAME=Newly Created CI User CI_JIRA_USER_PASSWORD=jira CI_JIRA_ISSUE=Task passenv = From b93cdb861412fd24fd833cf1305d8d6196711c4f Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Mon, 24 May 2021 08:37:32 +0100 Subject: [PATCH 057/103] fix pickling and hashability (#1051) * feat: fix pickling * feat: add default hash function * test: directly add test for picking Issue object --- jira/resources.py | 100 +++++++++++++++++++++++----------------------- tests/tests.py | 60 +++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 50 deletions(-) diff --git a/jira/resources.py b/jira/resources.py index d374503da..5a0db2ae8 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -122,6 +122,16 @@ class Resource(object): "closed", ) + # A list of properties that should uniquely identify a Resource object + # Each of these properties should be hashable, usually strings + _HASH_IDS = ( + "self", + "type", + "key", + "id", + "name", + ) + def __init__( self, resource: str, @@ -180,14 +190,13 @@ def __repr__(self) -> str: return f"" return f"" - def __getattr__(self, item: str): + def __getattr__(self, item: str) -> Any: """Allow access of attributes via names. Args: item (str): Attribute Name Raises: - KeyError: When the attribute does not exist. AttributeError: When attribute does not exist. Returns: @@ -196,17 +205,6 @@ def __getattr__(self, item: str): try: return self[item] # type: ignore except Exception as e: - # Make sure pickling doesn't break - # *MORE INFO*: This conditional wouldn't be necessary if __getattr__ wasn't used. But - # since it is in use (no worries), we need to give the pickle.dump* - # methods what they expect back. They expect to either get a KeyError - # exception or a tuple of args to be passed to the __new__ method upon - # unpickling (i.e. pickle.load* methods). - # *NOTE*: if the __new__ method were to be implemented in this class, this may have - # to be removed or changed. - if item == "__getnewargs__": - raise KeyError(item) - if hasattr(self, "raw") and self.raw is not None and item in self.raw: return self.raw[item] else: @@ -214,18 +212,46 @@ def __getattr__(self, item: str): f"{self.__class__!r} object has no attribute {item!r} ({e})" ) - # def __getstate__(self): - # """ - # Pickling the resource; using the raw dict - # """ - # return self.raw - # - # def __setstate__(self, raw_pickled): - # """ - # Unpickling of the resource - # """ - # self._parse_raw(raw_pickled) - # + def __getstate__(self) -> Dict[str, Any]: + """Pickling the resource.""" + return vars(self) + + def __setstate__(self, raw_pickled: Dict[str, Any]): + """Unpickling of the resource""" + # https://stackoverflow.com/a/50888571/7724187 + vars(self).update(raw_pickled) + + def __hash__(self) -> int: + """Hash calculation. + + We try to find unique identifier like properties + to form our hash object. + Technically 'self', if present, is the unique URL to the object, + and should be sufficient to generate a unique hash. + """ + hash_list = [] + for a in self._HASH_IDS: + if hasattr(self, a): + hash_list.append(getattr(self, a)) + + if hash_list: + return hash(tuple(hash_list)) + else: + raise TypeError(f"'{self.__class__}' is not hashable") + + def __eq__(self, other: Any) -> bool: + """Default equality test. + + Checks the types look about right and that the relevant + attributes that uniquely identify a resource are equal. + """ + return isinstance(other, self.__class__) and all( + [ + getattr(self, a) == getattr(other, a) + for a in self._HASH_IDS + if hasattr(self, a) + ] + ) def find( self, @@ -664,10 +690,6 @@ def permalink(self): """ return f"{self._options['server']}/browse/{self.key}" - def __eq__(self, other): - """Comparison method.""" - return other is not None and self.id == other.id - class Comment(Resource): """An issue comment.""" @@ -1011,14 +1033,6 @@ def __init__( if raw: self._parse_raw(raw) - def __hash__(self): - """Hash calculation.""" - return hash(str(self.name)) - - def __eq__(self, other): - """Comparison.""" - return str(self.name) == str(other.name) - class Group(Resource): """A Jira user group.""" @@ -1033,14 +1047,6 @@ def __init__( if raw: self._parse_raw(raw) - def __hash__(self): - """Hash calculation.""" - return hash(str(self.name)) - - def __eq__(self, other): - """Equality by name.""" - return str(self.name) == str(other.name) - class Version(Resource): """A version of a project.""" @@ -1106,10 +1112,6 @@ def update(self, **kwargs): super(Version, self).update(**data) - def __eq__(self, other): - """Comparison.""" - return self.id == other.id and self.name == other.name - # GreenHopper diff --git a/tests/tests.py b/tests/tests.py index 50701e781..ee77e31b8 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -362,7 +362,65 @@ def test_pickling_resource(self): self.jira._options, self.jira._session, raw=pickle.loads(pickled) ) self.assertEqual(resource.key, unpickled_instance.key) - self.assertTrue(resource == unpickled_instance) + # Class types are no longer equal, cls_for_resource() returns an Issue type + # find() returns a Resource type. So we compare the raw json + self.assertEqual(resource.raw, unpickled_instance.raw) + + def test_pickling_resource_class(self): + resource = self.jira.find("issue/{0}", self.test_manager.project_b_issue1) + + pickled = pickle.dumps(resource) + unpickled = pickle.loads(pickled) + + self.assertEqual(resource.key, unpickled.key) + self.assertEqual(resource, unpickled) + + def test_pickling_issue_class(self): + resource = self.test_manager.project_b_issue1_obj + + pickled = pickle.dumps(resource) + unpickled = pickle.loads(pickled) + + self.assertEqual(resource.key, unpickled.key) + self.assertEqual(resource, unpickled) + + def test_bad_attribute(self): + resource = self.jira.find("issue/{0}", self.test_manager.project_b_issue1) + + with self.assertRaises(AttributeError): + getattr(resource, "bogus123") + + def test_hashable(self): + resource = self.jira.find("issue/{0}", self.test_manager.project_b_issue1) + resource2 = self.jira.find("issue/{0}", self.test_manager.project_b_issue2) + + r1_hash = hash(resource) + r2_hash = hash(resource2) + + assert r1_hash != r2_hash + + dict_of_resource = {resource: "hey", resource2: "peekaboo"} + dict_of_resource.update({resource: "hey ho"}) + + assert len(dict_of_resource.keys()) == 2 + assert {resource, resource2} == set(dict_of_resource.keys()) + assert dict_of_resource[resource] == "hey ho" + + def test_hashable_issue_object(self): + resource = self.test_manager.project_b_issue1_obj + resource2 = self.test_manager.project_b_issue2_obj + + r1_hash = hash(resource) + r2_hash = hash(resource2) + + assert r1_hash != r2_hash + + dict_of_resource = {resource: "hey", resource2: "peekaboo"} + dict_of_resource.update({resource: "hey ho"}) + + assert len(dict_of_resource.keys()) == 2 + assert {resource, resource2} == set(dict_of_resource.keys()) + assert dict_of_resource[resource] == "hey ho" @flaky From f0e9cf24dcfcb3f76a8074e0a36ffa7096175d73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 May 2021 08:37:46 +0100 Subject: [PATCH 058/103] Bump markupsafe from 2.0.0 to 2.0.1 (#1048) Bumps [markupsafe](https://github.com/pallets/markupsafe) from 2.0.0 to 2.0.1. - [Release notes](https://github.com/pallets/markupsafe/releases) - [Changelog](https://github.com/pallets/markupsafe/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/markupsafe/compare/2.0.0...2.0.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 80a0c7c10..8d224ef8d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -36,7 +36,7 @@ idna==2.10 # via requests iniconfig==1.1.1 # via pytest -markupsafe==2.0.0 +markupsafe==2.0.1 # via -r test-requirements.in mypy-extensions==0.4.3 # via mypy From ba9eb2d7faa7189587157ebd13a3e073fa2811f8 Mon Sep 17 00:00:00 2001 From: William Lupton Date: Mon, 24 May 2021 08:38:08 +0100 Subject: [PATCH 059/103] Export resource_class_map and fix bug in Role.add_user() (#1047) The reason for exporting resource_class_map is to allow an external module to support additional resource types without the need to edit resources.py (I had edited it previously but this became a maintenance nightmare). The Role.add_user() bug is (I hope) an obvious one: the groups argument was ignored. --- jira/resources.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jira/resources.py b/jira/resources.py index 5a0db2ae8..e091cf9d8 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -46,6 +46,7 @@ "Customer", "ServiceDesk", "RequestType", + "resource_class_map", ) logging.getLogger("jira").addHandler(logging.NullHandler()) @@ -960,7 +961,7 @@ def add_user( if groups is not None and isinstance(groups, str): groups = (groups,) - data = {"user": users} # FIXME: groups is not used. + data = {"user": users, "group": groups} self._session.post(self.self, data=json.dumps(data)) From 7d229f47ba1230b3e6b6c0acb7136b5347f08649 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 May 2021 09:15:29 +0100 Subject: [PATCH 060/103] Bump pre-commit from 2.12.1 to 2.13.0 (#1054) Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 2.12.1 to 2.13.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/master/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v2.12.1...v2.13.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-requirements.txt b/test-requirements.txt index 8d224ef8d..dd0991709 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -52,7 +52,7 @@ packaging==20.9 # pytest-sugar pluggy==0.13.1 # via pytest -pre-commit==2.12.1 +pre-commit==2.13.0 # via -r test-requirements.in py==1.10.0 # via From 07f246a5b5bd3f1d94f26d5ddfda607fd3af681d Mon Sep 17 00:00:00 2001 From: Toni Dietze Date: Tue, 25 May 2021 09:53:39 +0200 Subject: [PATCH 061/103] ResilientSession: do not log request data on ConnectionError (#928) * ResilientSession: make maximal retry delay configurable * ResilientSession: do not log request data on ConnectionError The request data may contain secrets, e.g., a plaintext password when using basic auth. Therefore the request data shall not be logged. * test that no sensitive data is written to the log The test checks that no sensitive data is written to the log in case of a connection errors. --- jira/resilientsession.py | 6 +++-- tests/tests.py | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/jira/resilientsession.py b/jira/resilientsession.py index baa19904b..db1225751 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -94,6 +94,7 @@ class ResilientSession(Session): def __init__(self, timeout=None): self.max_retries = 3 + self.max_retry_delay = 60 self.timeout = timeout super(ResilientSession, self).__init__() @@ -129,7 +130,7 @@ def __recoverable( msg = "Atlassian's bug https://jira.atlassian.com/browse/JRA-41559" # Exponential backoff with full jitter. - delay = min(60, 10 * 2 ** counter) * random.random() + delay = min(self.max_retry_delay, 10 * 2 ** counter) * random.random() logging.warning( "Got recoverable error from %s %s, will retry [%s/%s] in %ss. Err: %s" % (request, url, counter, self.max_retries, delay, msg) @@ -166,7 +167,8 @@ def __verb( if response.status_code >= 200 and response.status_code <= 299: return response except ConnectionError as e: - logging.warning(f"{e} while doing {verb.upper()} {url} [{kwargs}]") + logging.warning(f"{e} while doing {verb.upper()} {url}") + exception = e retry_number += 1 diff --git a/tests/tests.py b/tests/tests.py index ee77e31b8..cfb20d75c 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -19,6 +19,7 @@ from flaky import flaky import jira # noqa +import jira.resilientsession from jira import JIRA, Issue, JIRAError, Project, Role # noqa from jira.resources import Group, Resource, UnknownResource, cls_for_resource # noqa @@ -2172,6 +2173,57 @@ def test_session_server_offline(self): self.assertTrue(False, "Instantiation of invalid JIRA instance succeeded.") +class ListLoggingHandler(logging.Handler): + """A logging handler that records all events in a list.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.records = [] + + def emit(self, record): + self.records.append(record) + + def reset(self): + self.records = [] + + +class ResilientSessionLoggingConfidentialityTests(unittest.TestCase): + """No sensitive data shall be written to the log.""" + + def setUp(self): + self.loggingHandler = ListLoggingHandler() + jira.resilientsession.logging.getLogger().addHandler(self.loggingHandler) + + def test_logging_with_connection_error(self): + """No sensitive data shall be written to the log in case of a connection error.""" + witness = "etwhpxbhfniqnbbjoqvw" # random string; hopefully unique + for max_retries in (0, 1): + for verb in ("get", "post", "put", "delete", "head", "patch", "options"): + with self.subTest(max_retries=max_retries, verb=verb): + with jira.resilientsession.ResilientSession() as session: + session.max_retries = max_retries + session.max_retry_delay = 0 + try: + getattr(session, verb)( + "http://127.0.0.1:9", + headers={"sensitive_header": witness}, + data={"sensitive_data": witness}, + ) + except jira.resilientsession.ConnectionError: + pass + # check that `witness` does not appear in log + for record in self.loggingHandler.records: + self.assertNotIn(witness, record.msg) + for arg in record.args: + self.assertNotIn(witness, str(arg)) + self.assertNotIn(witness, str(record)) + self.loggingHandler.reset() + + def tearDown(self): + jira.resilientsession.logging.getLogger().removeHandler(self.loggingHandler) + del self.loggingHandler + + class AsyncTests(unittest.TestCase): def setUp(self): self.jira = JIRA( From eb80088cd0da1d27043a2d457f2f045725ef97f0 Mon Sep 17 00:00:00 2001 From: Josh Heinrichs Date: Tue, 25 May 2021 01:54:32 -0600 Subject: [PATCH 062/103] Add expand to JIRA.project and JIRA.projects (#865) --- jira/client.py | 17 +++++++++++++---- tests/tests.py | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/jira/client.py b/jira/client.py index c51119245..88b4dc452 100644 --- a/jira/client.py +++ b/jira/client.py @@ -2401,30 +2401,39 @@ def priority(self, id: str) -> Priority: # Projects - def projects(self) -> List[Project]: + def projects(self, expand: Optional[str] = None) -> List[Project]: """Get a list of project Resources from the server visible to the current authenticated user. + Args: + expand (Optional[str]): extra information to fetch for each project + such as projectKeys and description. + Returns: List[Project] """ - r_json = self._get_json("project") + params = {} + if expand is not None: + params["expand"] = expand + r_json = self._get_json("project", params=params) projects = [ Project(self._options, self._session, raw_project_json) for raw_project_json in r_json ] return projects - def project(self, id: str) -> Project: + def project(self, id: str, expand: Optional[str] = None) -> Project: """Get a project Resource from the server. Args: id (str): ID or key of the project to get + expand (Optional[str]): extra information to fetch for the project + such as projectKeys and description. Returns: Project """ - return self._find_for_resource(Project, id) + return self._find_for_resource(Project, id, expand=expand) # non-resource @translate_resource_args diff --git a/tests/tests.py b/tests/tests.py index cfb20d75c..b9c8f2291 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -1549,10 +1549,24 @@ def test_projects(self): projects = self.jira.projects() self.assertGreaterEqual(len(projects), 2) + def test_projects_expand(self): + projects = self.jira.projects() + for project in projects: + self.assertFalse(hasattr(project, "projectKeys")) + projects = self.jira.projects(expand="projectKeys") + for project in projects: + self.assertTrue(hasattr(project, "projectKeys")) + def test_project(self): project = self.jira.project(self.project_b) self.assertEqual(project.key, self.project_b) + def test_project_expand(self): + project = self.jira.project(self.project_b) + self.assertFalse(hasattr(project, "projectKeys")) + project = self.jira.project(self.project_b, expand="projectKeys") + self.assertTrue(hasattr(project, "projectKeys")) + # I have no idea what avatars['custom'] is and I get different results every time # def test_project_avatars(self): # avatars = self.jira.project_avatars(self.project_b) From f96495a6ca90b037c477761db3ad7a1cad0aaff0 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 19 Jun 2021 12:29:44 +0100 Subject: [PATCH 063/103] make tests more approachable (#1045) * new files * update readme on how to debug tests * xpass now true pass * test exceptions, remove bad CI logic * resolution * fix merge, use user_normal instead of user_admin * add message to top of tests.py to guide new contributors * cleanup testmanager init --- README.rst | 31 + jira/exceptions.py | 17 +- tests/__init__.py | 7 + tests/conftest.py | 353 ++++ tests/resources/__init__.py | 5 + tests/resources/test_attachment.py | 29 + tests/resources/test_board.py | 0 tests/resources/test_comment.py | 69 + tests/resources/test_component.py | 82 + tests/resources/test_custom_field_option.py | 8 + tests/resources/test_customer.py | 0 tests/resources/test_dashboard.py | 26 + tests/resources/test_filter.py | 30 + tests/resources/test_generic_resource.py | 55 + tests/resources/test_group.py | 16 + tests/resources/test_issue.py | 503 +++++ tests/resources/test_issue_link.py | 41 + tests/resources/test_issue_link_type.py | 0 tests/resources/test_priority.py | 13 + tests/resources/test_project.py | 205 ++ tests/resources/test_remote_link.py | 119 ++ tests/resources/test_request_type.py | 0 tests/resources/test_resolution.py | 13 + tests/resources/test_role.py | 0 tests/resources/test_security_level.py | 9 + tests/resources/test_service_desk.py | 60 + tests/resources/test_sprint.py | 0 tests/resources/test_status.py | 16 + tests/resources/test_status_category.py | 20 + tests/resources/test_user.py | 185 ++ tests/resources/test_version.py | 58 + tests/resources/test_vote.py | 36 + tests/resources/test_watchers.py | 22 + tests/resources/test_worklog.py | 65 + tests/test_client.py | 4 +- tests/test_exceptions.py | 147 ++ tests/test_resilientsession.py | 55 + tests/tests.py | 2098 +------------------ 38 files changed, 2331 insertions(+), 2066 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/resources/__init__.py create mode 100644 tests/resources/test_attachment.py create mode 100644 tests/resources/test_board.py create mode 100644 tests/resources/test_comment.py create mode 100644 tests/resources/test_component.py create mode 100644 tests/resources/test_custom_field_option.py create mode 100644 tests/resources/test_customer.py create mode 100644 tests/resources/test_dashboard.py create mode 100644 tests/resources/test_filter.py create mode 100644 tests/resources/test_generic_resource.py create mode 100644 tests/resources/test_group.py create mode 100644 tests/resources/test_issue.py create mode 100644 tests/resources/test_issue_link.py create mode 100644 tests/resources/test_issue_link_type.py create mode 100644 tests/resources/test_priority.py create mode 100644 tests/resources/test_project.py create mode 100644 tests/resources/test_remote_link.py create mode 100644 tests/resources/test_request_type.py create mode 100644 tests/resources/test_resolution.py create mode 100644 tests/resources/test_role.py create mode 100644 tests/resources/test_security_level.py create mode 100644 tests/resources/test_service_desk.py create mode 100644 tests/resources/test_sprint.py create mode 100644 tests/resources/test_status.py create mode 100644 tests/resources/test_status_category.py create mode 100644 tests/resources/test_user.py create mode 100644 tests/resources/test_version.py create mode 100644 tests/resources/test_vote.py create mode 100644 tests/resources/test_watchers.py create mode 100644 tests/resources/test_worklog.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_resilientsession.py diff --git a/README.rst b/README.rst index b937ab534..4d8743d2a 100644 --- a/README.rst +++ b/README.rst @@ -100,12 +100,43 @@ Setup - ``tox`` * Run tests for one env only - ``tox -e py37`` + * Specify what tests to run with pytest_ + - ``tox -e py39 -- tests/resources/test_attachment.py`` + * Debug tests with breakpoints by disabling the coverage plugin, with the ``--no-cov`` argument. + - Example for VSCode on Windows : + + .. code-block:: java + + { + "name": "Pytest", + "type": "python", + "request": "launch", + "python": ".tox\\py39\\Scripts\\python.exe", + "module": "pytest", + "env": { + "CI_JIRA_URL": "http://localhost:2990/jira", + "CI_JIRA_ADMIN": "admin", + "CI_JIRA_ADMIN_PASSWORD": "admin", + "CI_JIRA_USER": "jira_user", + "CI_JIRA_USER_FULL_NAME": "Newly Created CI User", + "CI_JIRA_USER_PASSWORD": "jira", + "CI_JIRA_ISSUE": "Task", + "PYTEST_TIMEOUT": "0", // Don't timeout + }, + "args": [ + // "-v", + "--no-cov", // running coverage affects breakpoints + "tests/resources/test_attachment.py" + ] + } + * Build and publish with TWINE - ``tox -e publish`` .. _Fork: https://help.github.com/articles/fork-a-repo/ .. _sync: https://help.github.com/articles/syncing-a-fork/ .. _pyenv: https://amaral.northwestern.edu/resources/guides/pyenv-tutorial +.. _pytest: https://docs.pytest.org/en/stable/usage.html#specifying-tests-selecting-tests Credits diff --git a/jira/exceptions.py b/jira/exceptions.py index dddcc0787..8d2d01cb4 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -8,10 +8,6 @@ class JIRAError(Exception): """General error raised for all problems in operation of the client.""" - log_to_tempfile = True - if "GITHUB_ACTION" in os.environ: - log_to_tempfile = False # GitHub Actions is keeping only the console log. - def __init__( self, text: str = None, @@ -59,19 +55,14 @@ def __str__(self) -> str: if hasattr(self.response, "text"): details += f"\n\tresponse text = {self.response.text}" - # separate logging for CI makes sense. - if self.ci_run: - if self.text: - t += f"\n\ttext: {self.text}" - t += details - # Only log to tempfile if the option is set. - elif self.log_to_tempfile: - fd, file_name = tempfile.mkstemp(suffix=".tmp", prefix="jiraerror-") + if self.log_to_tempfile: + # Only log to tempfile if the option is set. + _, file_name = tempfile.mkstemp(suffix=".tmp", prefix="jiraerror-") with open(file_name, "w") as f: t += f" details: {file_name}" f.write(details) - # Otherwise, just return the error as usual else: + # Otherwise, just return the error as usual if self.text: t += f"\n\ttext: {self.text}" t += f"\n\t{details}" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..16c21845a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +"""All the tests for the jira package. + +Refer to conftest.py for shared helper methods. + +resources/test_* : For tests related to resources +test_* : For other tests of the non-resource elements of the jira package. +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..26a1d2ca8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,353 @@ +import getpass +import hashlib +import logging +import os +import random +import re +import string +import sys +import unittest +from time import sleep +from typing import Any, Dict + +import pytest +from flaky import flaky + +from jira import JIRA + +TEST_ROOT = os.path.dirname(__file__) +TEST_ICON_PATH = os.path.join(TEST_ROOT, "icon.png") +TEST_ATTACH_PATH = os.path.join(TEST_ROOT, "tests.py") + +LOGGER = logging.getLogger(__name__) + +OAUTH = False +CONSUMER_KEY = "oauth-consumer" +KEY_CERT_FILE = "/home/bspeakmon/src/atlassian-oauth-examples/rsa.pem" +KEY_CERT_DATA = None +try: + with open(KEY_CERT_FILE, "r") as cert: + KEY_CERT_DATA = cert.read() + OAUTH = True +except Exception: + OAUTH = False + + +ON_CUSTOM_JIRA = "CI_JIRA_URL" in os.environ + + +not_on_custom_jira_instance = pytest.mark.skipif( + ON_CUSTOM_JIRA, reason="Not applicable for custom Jira instance" +) +if ON_CUSTOM_JIRA: + LOGGER.info("Picked up custom Jira engine.") + + +broken_test = pytest.mark.xfail + + +@flaky # all have default flaki-ness +class JiraTestCase(unittest.TestCase): + """Test case for all Jira tests. + + This is the base class for all Jira tests that require access to the + Jira instance. + + It calls JiraTestManager() in the setUp() method. + setUp() is the method that is called **before** each test is run. + + Where possible follow the: + + * GIVEN - where you set up any pre-requisites e.g. the expected result + * WHEN - where you perform the action and obtain the result + * THEN - where you assert the expectation vs the result + + format for tests. + """ + + jira: JIRA # admin authenticated + jira_normal: JIRA # non-admin authenticated + + def setUp(self) -> None: + """ + This is called before each test. If you want to add more for your tests, + Run `JiraTestCase.setUp(self) in your custom setUp() to obtain these. + """ + self.test_manager = JiraTestManager() + self.jira = self.test_manager.jira_admin + self.jira_normal = self.test_manager.jira_normal + self.user_admin = self.test_manager.user_admin + self.user_normal = self.test_manager.user_normal # use this user where possible + self.project_b = self.test_manager.project_b + self.project_a = self.test_manager.project_a + + +def rndstr(): + return "".join(random.sample(string.ascii_lowercase, 6)) + + +def rndpassword(): + # generates a password of length 14 + s = ( + "".join(random.sample(string.ascii_uppercase, 5)) + + "".join(random.sample(string.ascii_lowercase, 5)) + + "".join(random.sample(string.digits, 2)) + + "".join(random.sample("~`!@#$%^&*()_+-=[]\\{}|;':<>?,./", 2)) + ) + return "".join(random.sample(s, len(s))) + + +def hashify(some_string, max_len=8): + return hashlib.sha256(some_string.encode("utf-8")).hexdigest()[:8].upper() + + +def get_unique_project_name(): + user = re.sub("[^A-Z_]", "", getpass.getuser().upper()) + if "GITHUB_ACTION" in os.environ and "GITHUB_RUN_NUMBER" in os.environ: + # please note that user underline (_) is not supported by + # Jira even if it is documented as supported. + return "GH" + hashify(user + os.environ["GITHUB_RUN_NUMBER"]) + identifier = ( + user + chr(ord("A") + sys.version_info[0]) + chr(ord("A") + sys.version_info[1]) + ) + return "Z" + hashify(identifier) + + +class JiraTestManager(object): + """Instantiate and populate the JIRA instance with data for tests. + + Attributes: + CI_JIRA_ADMIN (str): Admin user account name. + CI_JIRA_USER (str): Limited user account name. + max_retries (int): number of retries to perform for recoverable HTTP errors. + """ + + __shared_state: Dict[Any, Any] = {} + + def __init__(self, jira_hosted_type="Server"): + """Instantiate and populate the JIRA instance""" + self.__dict__ = self.__shared_state + + if not self.__dict__: + self.initialized = False + self.max_retries = 5 + + if jira_hosted_type and jira_hosted_type == "Cloud": + self.set_jira_cloud_details() + else: + self.set_jira_server_details() + + jira_class_kwargs = { + "server": self.CI_JIRA_URL, + "logging": False, + "validate": True, + "max_retries": self.max_retries, + } + if OAUTH: + self.set_oauth_logins() + else: + self.set_basic_auth_logins(**jira_class_kwargs) + + if not self.jira_admin.current_user(): + self.initialized = True + sys.exit(3) + + # now we need to create some data to start with for the tests + self.create_some_data() + + if not hasattr(self, "jira_normal") or not hasattr(self, "jira_admin"): + pytest.exit("FATAL: WTF!?") + + self.user_admin = self.jira_admin.search_users(self.CI_JIRA_ADMIN)[0] + self.user_normal = self.jira_admin.search_users(self.CI_JIRA_USER)[0] + self.initialized = True + + def set_jira_cloud_details(self): + self.CI_JIRA_URL = "https://pycontribs.atlassian.net" + self.CI_JIRA_ADMIN = "ci-admin" + self.CI_JIRA_ADMIN_PASSWORD = "sd4s3dgec5fhg4tfsds3434" + self.CI_JIRA_USER = "ci-user" + self.CI_JIRA_USER_PASSWORD = "sd4s3dgec5fhg4tfsds3434" + + def set_jira_server_details(self): + self.CI_JIRA_URL = os.environ["CI_JIRA_URL"] + self.CI_JIRA_ADMIN = os.environ["CI_JIRA_ADMIN"] + self.CI_JIRA_ADMIN_PASSWORD = os.environ["CI_JIRA_ADMIN_PASSWORD"] + self.CI_JIRA_USER = os.environ["CI_JIRA_USER"] + self.CI_JIRA_USER_PASSWORD = os.environ["CI_JIRA_USER_PASSWORD"] + self.CI_JIRA_ISSUE = os.environ.get("CI_JIRA_ISSUE", "Bug") + + def set_oauth_logins(self): + self.jira_admin = JIRA( + oauth={ + "access_token": "hTxcwsbUQiFuFALf7KZHDaeAJIo3tLUK", + "access_token_secret": "aNCLQFP3ORNU6WY7HQISbqbhf0UudDAf", + "consumer_key": CONSUMER_KEY, + "key_cert": KEY_CERT_DATA, + } + ) + self.jira_sysadmin = JIRA( + oauth={ + "access_token": "4ul1ETSFo7ybbIxAxzyRal39cTrwEGFv", + "access_token_secret": "K83jBZnjnuVRcfjBflrKyThJa0KSjSs2", + "consumer_key": CONSUMER_KEY, + "key_cert": KEY_CERT_DATA, + }, + logging=False, + max_retries=self.max_retries, + ) + self.jira_normal = JIRA( + oauth={ + "access_token": "ZVDgYDyIQqJY8IFlQ446jZaURIz5ECiB", + "access_token_secret": "5WbLBybPDg1lqqyFjyXSCsCtAWTwz1eD", + "consumer_key": CONSUMER_KEY, + "key_cert": KEY_CERT_DATA, + } + ) + + def set_basic_auth_logins(self, **jira_class_kwargs): + if self.CI_JIRA_ADMIN: + self.jira_admin = JIRA( + basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), + **jira_class_kwargs, + ) + self.jira_sysadmin = JIRA( + basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), + **jira_class_kwargs, + ) + self.jira_normal = JIRA( + basic_auth=(self.CI_JIRA_USER, self.CI_JIRA_USER_PASSWORD), + **jira_class_kwargs, + ) + else: + # Setup some un-authenticated users + self.jira_admin = JIRA(self.CI_JIRA_URL, **jira_class_kwargs) + self.jira_sysadmin = JIRA(self.CI_JIRA_URL, **jira_class_kwargs) + self.jira_normal = JIRA(self.CI_JIRA_URL, **jira_class_kwargs) + + def create_some_data(self): + """Create some data for the tests""" + + # jira project key is max 10 chars, no letter. + # [0] always "Z" + # [1-6] username running the tests (hope we will not collide) + # [7-8] python version A=0, B=1,.. + # [9] A,B -- we may need more than one project + + """ `jid` is important for avoiding concurrency problems when + executing tests in parallel as we have only one test instance. + + jid length must be less than 9 characters because we may append + another one and the Jira Project key length limit is 10. + """ + + self.jid = get_unique_project_name() + + self.project_a = self.jid + "A" # old XSS + self.project_a_name = "Test user=%s key=%s A" % ( + getpass.getuser(), + self.project_a, + ) + self.project_b = self.jid + "B" # old BULK + self.project_b_name = "Test user=%s key=%s B" % ( + getpass.getuser(), + self.project_b, + ) + self.project_sd = self.jid + "C" + self.project_sd_name = "Test user=%s key=%s C" % ( + getpass.getuser(), + self.project_sd, + ) + + # TODO(ssbarnea): find a way to prevent SecurityTokenMissing for On Demand + # https://jira.atlassian.com/browse/JRA-39153 + try: + self.jira_admin.project(self.project_a) + except Exception as e: + LOGGER.warning(e) + else: + try: + self.jira_admin.delete_project(self.project_a) + except Exception as e: + LOGGER.warning("Failed to delete %s\n%s", self.project_a, e) + + try: + self.jira_admin.project(self.project_b) + except Exception as e: + LOGGER.warning(e) + else: + try: + self.jira_admin.delete_project(self.project_b) + except Exception as e: + LOGGER.warning("Failed to delete %s\n%s", self.project_b, e) + + # wait for the project to be deleted + for _ in range(1, 20): + try: + self.jira_admin.project(self.project_b) + except Exception: + break + print("Warning: Project not deleted yet....") + sleep(2) + + for _ in range(6): + try: + if self.jira_admin.create_project(self.project_a, self.project_a_name): + break + except Exception as e: + if "A project with that name already exists" not in str(e): + raise e + self.project_a_id = self.jira_admin.project(self.project_a).id + self.jira_admin.create_project(self.project_b, self.project_b_name) + + try: + self.jira_admin.create_project(self.project_b, self.project_b_name) + except Exception: + # we care only for the project to exist + pass + sleep(1) # keep it here as often Jira will report the + # project as missing even after is created + self.project_b_issue1_obj = self.jira_admin.create_issue( + project=self.project_b, + summary="issue 1 from %s" % self.project_b, + issuetype=self.CI_JIRA_ISSUE, + ) + self.project_b_issue1 = self.project_b_issue1_obj.key + + self.project_b_issue2_obj = self.jira_admin.create_issue( + project=self.project_b, + summary="issue 2 from %s" % self.project_b, + issuetype={"name": self.CI_JIRA_ISSUE}, + ) + self.project_b_issue2 = self.project_b_issue2_obj.key + + self.project_b_issue3_obj = self.jira_admin.create_issue( + project=self.project_b, + summary="issue 3 from %s" % self.project_b, + issuetype={"name": self.CI_JIRA_ISSUE}, + ) + self.project_b_issue3 = self.project_b_issue3_obj.key + + +def find_by_key(seq, key): + for seq_item in seq: + if seq_item["key"] == key: + return seq_item + + +def find_by_key_value(seq, key): + for seq_item in seq: + if seq_item.key == key: + return seq_item + + +def find_by_id(seq, id): + for seq_item in seq: + if seq_item.id == id: + return seq_item + + +def find_by_name(seq, name): + for seq_item in seq: + if seq_item["name"] == name: + return seq_item diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py new file mode 100644 index 000000000..845fcce77 --- /dev/null +++ b/tests/resources/__init__.py @@ -0,0 +1,5 @@ +"""Tests grouped by Resource type + +The resources/ folder contains tests grouped per +jira.resource.Resource with files for each of its subclasses. +""" diff --git a/tests/resources/test_attachment.py b/tests/resources/test_attachment.py new file mode 100644 index 000000000..667a39afd --- /dev/null +++ b/tests/resources/test_attachment.py @@ -0,0 +1,29 @@ +import os + +from tests.conftest import TEST_ATTACH_PATH, JiraTestCase + + +class AttachmentTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + self.attachment = None + + def test_0_attachment_meta(self): + meta = self.jira.attachment_meta() + self.assertTrue(meta["enabled"]) + # we have no control over server side upload limit + self.assertIn("uploadLimit", meta) + + def test_1_add_remove_attachment(self): + issue = self.jira.issue(self.issue_1) + with open(TEST_ATTACH_PATH, "rb") as f: + attachment = self.jira.add_attachment(issue, f, "new test attachment") + new_attachment = self.jira.attachment(attachment.id) + msg = "attachment %s of issue %s" % (new_attachment.__dict__, issue) + self.assertEqual(new_attachment.filename, "new test attachment", msg=msg) + self.assertEqual( + new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg + ) + # JIRA returns a HTTP 204 upon successful deletion + self.assertEqual(attachment.delete().status_code, 204) diff --git a/tests/resources/test_board.py b/tests/resources/test_board.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/test_comment.py b/tests/resources/test_comment.py new file mode 100644 index 000000000..950ec4571 --- /dev/null +++ b/tests/resources/test_comment.py @@ -0,0 +1,69 @@ +from tests.conftest import JiraTestCase + + +class CommentTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + self.issue_2 = self.test_manager.project_b_issue2 + self.issue_3 = self.test_manager.project_b_issue3 + + def test_comments(self): + for issue in [self.issue_1, self.jira.issue(self.issue_2)]: + self.jira.issue(issue) + comment1 = self.jira.add_comment(issue, "First comment") + comment2 = self.jira.add_comment(issue, "Second comment") + comments = self.jira.comments(issue) + assert comments[0].body == "First comment" + assert comments[1].body == "Second comment" + comment1.delete() + comment2.delete() + comments = self.jira.comments(issue) + assert len(comments) == 0 + + def test_expanded_comments(self): + comment1 = self.jira.add_comment(self.issue_1, "First comment") + comment2 = self.jira.add_comment(self.issue_1, "Second comment") + comments = self.jira.comments(self.issue_1, expand="renderedBody") + self.assertTrue(hasattr(comments[0], "renderedBody")) + ret_comment1 = self.jira.comment( + self.issue_1, comment1.id, expand="renderedBody" + ) + ret_comment2 = self.jira.comment(self.issue_1, comment2.id) + comment1.delete() + comment2.delete() + self.assertTrue(hasattr(ret_comment1, "renderedBody")) + self.assertFalse(hasattr(ret_comment2, "renderedBody")) + comments = self.jira.comments(self.issue_1) + assert len(comments) == 0 + + def test_add_comment(self): + comment = self.jira.add_comment( + self.issue_3, + "a test comment!", + visibility={"type": "role", "value": "Administrators"}, + ) + self.assertEqual(comment.body, "a test comment!") + self.assertEqual(comment.visibility.type, "role") + self.assertEqual(comment.visibility.value, "Administrators") + comment.delete() + + def test_add_comment_with_issue_obj(self): + issue = self.jira.issue(self.issue_3) + comment = self.jira.add_comment( + issue, + "a new test comment!", + visibility={"type": "role", "value": "Administrators"}, + ) + self.assertEqual(comment.body, "a new test comment!") + self.assertEqual(comment.visibility.type, "role") + self.assertEqual(comment.visibility.value, "Administrators") + comment.delete() + + def test_update_comment(self): + comment = self.jira.add_comment(self.issue_3, "updating soon!") + comment.update(body="updated!") + self.assertEqual(comment.body, "updated!") + # self.assertEqual(comment.visibility.type, 'role') + # self.assertEqual(comment.visibility.value, 'Administrators') + comment.delete() diff --git a/tests/resources/test_component.py b/tests/resources/test_component.py new file mode 100644 index 000000000..b802c52d0 --- /dev/null +++ b/tests/resources/test_component.py @@ -0,0 +1,82 @@ +from jira.exceptions import JIRAError +from tests.conftest import JiraTestCase, rndstr + + +class ComponentTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + self.issue_2 = self.test_manager.project_b_issue2 + + def test_2_create_component(self): + proj = self.jira.project(self.project_b) + name = "project-%s-component-%s" % (proj, rndstr()) + component = self.jira.create_component( + name, + proj, + description="test!!", + assigneeType="COMPONENT_LEAD", + isAssigneeTypeValid=False, + ) + self.assertEqual(component.name, name) + self.assertEqual(component.description, "test!!") + self.assertEqual(component.assigneeType, "COMPONENT_LEAD") + self.assertFalse(component.isAssigneeTypeValid) + component.delete() + + # Components field can't be modified from issue.update + # def test_component_count_related_issues(self): + # component = self.jira.create_component('PROJECT_B_TEST',self.project_b, description='test!!', + # assigneeType='COMPONENT_LEAD', isAssigneeTypeValid=False) + # issue1 = self.jira.issue(self.issue_1) + # issue2 = self.jira.issue(self.issue_2) + # (issue1.update ({'components': ['PROJECT_B_TEST']})) + # (issue2.update (components = ['PROJECT_B_TEST'])) + # issue_count = self.jira.component_count_related_issues(component.id) + # self.assertEqual(issue_count, 2) + # component.delete() + + def test_3_update(self): + try: + components = self.jira.project_components(self.project_b) + for component in components: + if component.name == "To be updated": + component.delete() + break + except Exception: + # We ignore errors as this code intends only to prepare for + # component creation + raise + + name = "component-" + rndstr() + + component = self.jira.create_component( + name, + self.project_b, + description="stand by!", + leadUserName=self.jira.current_user(), + ) + name = "renamed-" + name + component.update( + name=name, description="It is done.", leadUserName=self.jira.current_user() + ) + self.assertEqual(component.name, name) + self.assertEqual(component.description, "It is done.") + self.assertEqual(component.lead.name, self.jira.current_user()) + component.delete() + + def test_4_delete(self): + component = self.jira.create_component( + "To be deleted", self.project_b, description="not long for this world" + ) + myid = component.id + component.delete() + self.assertRaises(JIRAError, self.jira.component, myid) + + def test_delete_component_by_id(self): + component = self.jira.create_component( + "To be deleted", self.project_b, description="not long for this world" + ) + myid = component.id + self.jira.delete_component(myid) + self.assertRaises(JIRAError, self.jira.component, myid) diff --git a/tests/resources/test_custom_field_option.py b/tests/resources/test_custom_field_option.py new file mode 100644 index 000000000..cd2908193 --- /dev/null +++ b/tests/resources/test_custom_field_option.py @@ -0,0 +1,8 @@ +from tests.conftest import JiraTestCase, not_on_custom_jira_instance + + +class CustomFieldOptionTests(JiraTestCase): + @not_on_custom_jira_instance + def test_custom_field_option(self): + option = self.jira.custom_field_option("10001") + self.assertEqual(option.value, "To Do") diff --git a/tests/resources/test_customer.py b/tests/resources/test_customer.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/test_dashboard.py b/tests/resources/test_dashboard.py new file mode 100644 index 000000000..76faddddb --- /dev/null +++ b/tests/resources/test_dashboard.py @@ -0,0 +1,26 @@ +from tests.conftest import JiraTestCase, not_on_custom_jira_instance + + +@not_on_custom_jira_instance +class DashboardTests(JiraTestCase): + def test_dashboards(self): + dashboards = self.jira.dashboards() + self.assertEqual(len(dashboards), 3) + + def test_dashboards_filter(self): + dashboards = self.jira.dashboards(filter="my") + self.assertEqual(len(dashboards), 2) + self.assertEqual(dashboards[0].id, "10101") + + def test_dashboards_startat(self): + dashboards = self.jira.dashboards(startAt=1, maxResults=1) + self.assertEqual(len(dashboards), 1) + + def test_dashboards_maxresults(self): + dashboards = self.jira.dashboards(maxResults=1) + self.assertEqual(len(dashboards), 1) + + def test_dashboard(self): + dashboard = self.jira.dashboard("10101") + self.assertEqual(dashboard.id, "10101") + self.assertEqual(dashboard.name, "Another test dashboard") diff --git a/tests/resources/test_filter.py b/tests/resources/test_filter.py new file mode 100644 index 000000000..43d2b705e --- /dev/null +++ b/tests/resources/test_filter.py @@ -0,0 +1,30 @@ +from tests.conftest import JiraTestCase, rndstr + + +class FilterTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + self.issue_2 = self.test_manager.project_b_issue2 + + def test_filter(self): + jql = "project = %s and component is not empty" % self.project_b + name = "same filter " + rndstr() + myfilter = self.jira.create_filter( + name=name, description="just some new test filter", jql=jql, favourite=False + ) + self.assertEqual(myfilter.name, name) + self.assertEqual(myfilter.owner.name, self.test_manager.user_admin.name) + myfilter.delete() + + def test_favourite_filters(self): + # filters = self.jira.favourite_filters() + jql = "project = %s and component is not empty" % self.project_b + name = "filter-to-fav-" + rndstr() + myfilter = self.jira.create_filter( + name=name, description="just some new test filter", jql=jql, favourite=True + ) + new_filters = self.jira.favourite_filters() + + assert name in [f.name for f in new_filters] + myfilter.delete() diff --git a/tests/resources/test_generic_resource.py b/tests/resources/test_generic_resource.py new file mode 100644 index 000000000..16030b5c6 --- /dev/null +++ b/tests/resources/test_generic_resource.py @@ -0,0 +1,55 @@ +import unittest + +from flaky import flaky + +from jira.resources import ( + Group, + Issue, + Project, + Role, + UnknownResource, + cls_for_resource, +) + + +@flaky +class ResourceTests(unittest.TestCase): + def setUp(self): + pass + + def test_cls_for_resource(self): + self.assertEqual( + cls_for_resource( + "https://jira.atlassian.com/rest/\ + api/latest/issue/JRA-1330" + ), + Issue, + ) + self.assertEqual( + cls_for_resource( + "http://localhost:2990/jira/rest/\ + api/latest/project/BULK" + ), + Project, + ) + self.assertEqual( + cls_for_resource( + "http://imaginary-jira.com/rest/\ + api/latest/project/IMG/role/10002" + ), + Role, + ) + self.assertEqual( + cls_for_resource( + "http://customized-jira.com/rest/\ + plugin-resource/4.5/json/getMyObject" + ), + UnknownResource, + ) + self.assertEqual( + cls_for_resource( + "http://customized-jira.com/rest/\ + group?groupname=bla" + ), + Group, + ) diff --git a/tests/resources/test_group.py b/tests/resources/test_group.py new file mode 100644 index 000000000..d92187c79 --- /dev/null +++ b/tests/resources/test_group.py @@ -0,0 +1,16 @@ +from tests.conftest import JiraTestCase, not_on_custom_jira_instance + + +@not_on_custom_jira_instance +class GroupsTest(JiraTestCase): + def test_group(self): + group = self.jira.group("jira-users") + self.assertEqual(group.name, "jira-users") + + def test_groups(self): + groups = self.jira.groups() + self.assertGreater(len(groups), 0) + + def test_groups_for_users(self): + groups = self.jira.groups("jira-users") + self.assertGreater(len(groups), 0) diff --git a/tests/resources/test_issue.py b/tests/resources/test_issue.py new file mode 100644 index 000000000..1587d31c2 --- /dev/null +++ b/tests/resources/test_issue.py @@ -0,0 +1,503 @@ +import logging +import re +from time import sleep + +from jira.exceptions import JIRAError +from tests.conftest import ( + JiraTestCase, + broken_test, + find_by_key, + find_by_key_value, + not_on_custom_jira_instance, + rndstr, +) + +LOGGER = logging.getLogger(__name__) + + +class IssueTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + self.issue_2 = self.test_manager.project_b_issue2 + self.issue_3 = self.test_manager.project_b_issue3 + + def test_issue(self): + issue = self.jira.issue(self.issue_1) + self.assertEqual(issue.key, self.issue_1) + self.assertEqual(issue.fields.summary, "issue 1 from %s" % self.project_b) + + @broken_test(reason="disabled as it seems to be ignored by jira, returning all") + def test_issue_field_limiting(self): + issue = self.jira.issue(self.issue_2, fields="summary,comment") + self.assertEqual(issue.fields.summary, "issue 2 from %s" % self.project_b) + comment1 = self.jira.add_comment(issue, "First comment") + comment2 = self.jira.add_comment(issue, "Second comment") + comment3 = self.jira.add_comment(issue, "Third comment") + self.jira.issue(self.issue_2, fields="summary,comment") + LOGGER.warning(issue.raw["fields"]) + self.assertFalse(hasattr(issue.fields, "reporter")) + self.assertFalse(hasattr(issue.fields, "progress")) + comment1.delete() + comment2.delete() + comment3.delete() + + def test_issue_equal(self): + issue1 = self.jira.issue(self.issue_1) + issue2 = self.jira.issue(self.issue_2) + issues = self.jira.search_issues("key=%s" % self.issue_1) + self.assertTrue(issue1 is not None) + self.assertTrue(issue1 == issues[0]) + self.assertFalse(issue2 == issues[0]) + + def test_issue_expand(self): + issue = self.jira.issue(self.issue_1, expand="editmeta,schema") + self.assertTrue(hasattr(issue, "editmeta")) + self.assertTrue(hasattr(issue, "schema")) + # testing for changelog is not reliable because it may exist or not based on test order + # self.assertFalse(hasattr(issue, 'changelog')) + + @not_on_custom_jira_instance + def test_create_issue_with_fieldargs(self): + issue = self.jira.create_issue( + project=self.project_b, + summary="Test issue created", + description="foo description", + issuetype={"name": "Bug"}, + ) # customfield_10022='XSS' + self.assertEqual(issue.fields.summary, "Test issue created") + self.assertEqual(issue.fields.description, "foo description") + self.assertEqual(issue.fields.issuetype.name, "Bug") + self.assertEqual(issue.fields.project.key, self.project_b) + # self.assertEqual(issue.fields.customfield_10022, 'XSS') + issue.delete() + + @not_on_custom_jira_instance + def test_create_issue_with_fielddict(self): + fields = { + "project": {"key": self.project_b}, + "summary": "Issue created from field dict", + "description": "Some new issue for test", + "issuetype": {"name": "Bug"}, + # 'customfield_10022': 'XSS', + "priority": {"name": "Major"}, + } + issue = self.jira.create_issue(fields=fields) + self.assertEqual(issue.fields.summary, "Issue created from field dict") + self.assertEqual(issue.fields.description, "Some new issue for test") + self.assertEqual(issue.fields.issuetype.name, "Bug") + self.assertEqual(issue.fields.project.key, self.project_b) + # self.assertEqual(issue.fields.customfield_10022, 'XSS') + self.assertEqual(issue.fields.priority.name, "Major") + issue.delete() + + @not_on_custom_jira_instance + def test_create_issue_without_prefetch(self): + issue = self.jira.create_issue( + prefetch=False, + project=self.project_b, + summary="Test issue created", + description="some details", + issuetype={"name": "Bug"}, + ) # customfield_10022='XSS' + + assert hasattr(issue, "self") + assert hasattr(issue, "raw") + assert "fields" not in issue.raw + issue.delete() + + @not_on_custom_jira_instance + def test_create_issues(self): + field_list = [ + { + "project": {"key": self.project_b}, + "summary": "Issue created via bulk create #1", + "description": "Some new issue for test", + "issuetype": {"name": "Bug"}, + # 'customfield_10022': 'XSS', + "priority": {"name": "Major"}, + }, + { + "project": {"key": self.project_a}, + "issuetype": {"name": "Bug"}, + "summary": "Issue created via bulk create #2", + "description": "Another new issue for bulk test", + "priority": {"name": "Major"}, + }, + ] + issues = self.jira.create_issues(field_list=field_list) + self.assertEqual(len(issues), 2) + self.assertIsNotNone(issues[0]["issue"], "the first issue has not been created") + self.assertEqual( + issues[0]["issue"].fields.summary, "Issue created via bulk create #1" + ) + self.assertEqual( + issues[0]["issue"].fields.description, "Some new issue for test" + ) + self.assertEqual(issues[0]["issue"].fields.issuetype.name, "Bug") + self.assertEqual(issues[0]["issue"].fields.project.key, self.project_b) + self.assertEqual(issues[0]["issue"].fields.priority.name, "Major") + self.assertIsNotNone( + issues[1]["issue"], "the second issue has not been created" + ) + self.assertEqual( + issues[1]["issue"].fields.summary, "Issue created via bulk create #2" + ) + self.assertEqual( + issues[1]["issue"].fields.description, "Another new issue for bulk test" + ) + self.assertEqual(issues[1]["issue"].fields.issuetype.name, "Bug") + self.assertEqual(issues[1]["issue"].fields.project.key, self.project_a) + self.assertEqual(issues[1]["issue"].fields.priority.name, "Major") + for issue in issues: + issue["issue"].delete() + + @not_on_custom_jira_instance + def test_create_issues_one_failure(self): + field_list = [ + { + "project": {"key": self.project_b}, + "summary": "Issue created via bulk create #1", + "description": "Some new issue for test", + "issuetype": {"name": "Bug"}, + # 'customfield_10022': 'XSS', + "priority": {"name": "Major"}, + }, + { + "project": {"key": self.project_a}, + "issuetype": {"name": "InvalidIssueType"}, + "summary": "This issue will not succeed", + "description": "Should not be seen.", + "priority": {"name": "Blah"}, + }, + { + "project": {"key": self.project_a}, + "issuetype": {"name": "Bug"}, + "summary": "However, this one will.", + "description": "Should be seen.", + "priority": {"name": "Major"}, + }, + ] + issues = self.jira.create_issues(field_list=field_list) + self.assertEqual( + issues[0]["issue"].fields.summary, "Issue created via bulk create #1" + ) + self.assertEqual( + issues[0]["issue"].fields.description, "Some new issue for test" + ) + self.assertEqual(issues[0]["issue"].fields.issuetype.name, "Bug") + self.assertEqual(issues[0]["issue"].fields.project.key, self.project_b) + self.assertEqual(issues[0]["issue"].fields.priority.name, "Major") + self.assertEqual(issues[0]["error"], None) + self.assertEqual(issues[1]["issue"], None) + self.assertEqual(issues[1]["error"], {"issuetype": "issue type is required"}) + self.assertEqual(issues[1]["input_fields"], field_list[1]) + self.assertEqual(issues[2]["issue"].fields.summary, "However, this one will.") + self.assertEqual(issues[2]["issue"].fields.description, "Should be seen.") + self.assertEqual(issues[2]["issue"].fields.issuetype.name, "Bug") + self.assertEqual(issues[2]["issue"].fields.project.key, self.project_a) + self.assertEqual(issues[2]["issue"].fields.priority.name, "Major") + self.assertEqual(issues[2]["error"], None) + self.assertEqual(len(issues), 3) + for issue in issues: + if issue["issue"] is not None: + issue["issue"].delete() + + @not_on_custom_jira_instance + def test_create_issues_without_prefetch(self): + field_list = [ + dict( + project=self.project_b, + summary="Test issue created", + description="some details", + issuetype={"name": "Bug"}, + ), + dict( + project=self.project_a, + summary="Test issue #2", + description="foo description", + issuetype={"name": "Bug"}, + ), + ] + issues = self.jira.create_issues(field_list, prefetch=False) + + assert hasattr(issues[0]["issue"], "self") + assert hasattr(issues[0]["issue"], "raw") + assert hasattr(issues[1]["issue"], "self") + assert hasattr(issues[1]["issue"], "raw") + assert "fields" not in issues[0]["issue"].raw + assert "fields" not in issues[1]["issue"].raw + for issue in issues: + issue["issue"].delete() + + @not_on_custom_jira_instance + def test_update_with_fieldargs(self): + issue = self.jira.create_issue( + project=self.project_b, + summary="Test issue for updating", + description="Will be updated shortly", + issuetype={"name": "Bug"}, + ) + # customfield_10022='XSS') + issue.update( + summary="Updated summary", + description="Now updated", + issuetype={"name": "Story"}, + ) + self.assertEqual(issue.fields.summary, "Updated summary") + self.assertEqual(issue.fields.description, "Now updated") + self.assertEqual(issue.fields.issuetype.name, "Story") + # self.assertEqual(issue.fields.customfield_10022, 'XSS') + self.assertEqual(issue.fields.project.key, self.project_b) + issue.delete() + + @not_on_custom_jira_instance + def test_update_with_fielddict(self): + issue = self.jira.create_issue( + project=self.project_b, + summary="Test issue for updating", + description="Will be updated shortly", + issuetype={"name": "Bug"}, + ) + fields = { + "summary": "Issue is updated", + "description": "it sure is", + "issuetype": {"name": "Story"}, + # 'customfield_10022': 'DOC', + "priority": {"name": "Major"}, + } + issue.update(fields=fields) + self.assertEqual(issue.fields.summary, "Issue is updated") + self.assertEqual(issue.fields.description, "it sure is") + self.assertEqual(issue.fields.issuetype.name, "Story") + # self.assertEqual(issue.fields.customfield_10022, 'DOC') + self.assertEqual(issue.fields.priority.name, "Major") + issue.delete() + + def test_update_with_label(self): + issue = self.jira.create_issue( + project=self.project_b, + summary="Test issue for updating labels", + description="Label testing", + issuetype=self.test_manager.CI_JIRA_ISSUE, + ) + + labelarray = ["testLabel"] + fields = {"labels": labelarray} + + issue.update(fields=fields) + self.assertEqual(issue.fields.labels, ["testLabel"]) + + def test_update_with_bad_label(self): + issue = self.jira.create_issue( + project=self.project_b, + summary="Test issue for updating labels", + description="Label testing", + issuetype=self.test_manager.CI_JIRA_ISSUE, + ) + + issue.fields.labels.append("this should not work") + + fields = {"labels": issue.fields.labels} + + self.assertRaises(JIRAError, issue.update, fields=fields) + + @not_on_custom_jira_instance + def test_update_with_notify_false(self): + issue = self.jira.create_issue( + project=self.project_b, + summary="Test issue for updating", + description="Will be updated shortly", + issuetype={"name": "Bug"}, + ) + issue.update(notify=False, description="Now updated, but silently") + self.assertEqual(issue.fields.description, "Now updated, but silently") + issue.delete() + + def test_delete(self): + issue = self.jira.create_issue( + project=self.project_b, + summary="Test issue created", + description="Not long for this world", + issuetype=self.test_manager.CI_JIRA_ISSUE, + ) + key = issue.key + issue.delete() + self.assertRaises(JIRAError, self.jira.issue, key) + + @not_on_custom_jira_instance + def test_createmeta(self): + meta = self.jira.createmeta() + proj = find_by_key(meta["projects"], self.project_b) + # we assume that this project should allow at least one issue type + self.assertGreaterEqual(len(proj["issuetypes"]), 1) + + @not_on_custom_jira_instance + def test_createmeta_filter_by_projectkey_and_name(self): + meta = self.jira.createmeta(projectKeys=self.project_b, issuetypeNames="Bug") + self.assertEqual(len(meta["projects"]), 1) + self.assertEqual(len(meta["projects"][0]["issuetypes"]), 1) + + @not_on_custom_jira_instance + def test_createmeta_filter_by_projectkeys_and_name(self): + meta = self.jira.createmeta( + projectKeys=(self.project_a, self.project_b), issuetypeNames="Story" + ) + self.assertEqual(len(meta["projects"]), 2) + for project in meta["projects"]: + self.assertEqual(len(project["issuetypes"]), 1) + + @not_on_custom_jira_instance + def test_createmeta_filter_by_id(self): + projects = self.jira.projects() + proja = find_by_key_value(projects, self.project_a) + projb = find_by_key_value(projects, self.project_b) + issue_type_ids = dict() + full_meta = self.jira.createmeta(projectIds=(proja.id, projb.id)) + for project in full_meta["projects"]: + for issue_t in project["issuetypes"]: + issue_t_id = issue_t["id"] + val = issue_type_ids.get(issue_t_id) + if val is None: + issue_type_ids[issue_t_id] = [] + issue_type_ids[issue_t_id].append([project["id"]]) + common_issue_ids = [] + for key, val in issue_type_ids.items(): + if len(val) == 2: + common_issue_ids.append(key) + self.assertNotEqual(len(common_issue_ids), 0) + for_lookup_common_issue_ids = common_issue_ids + if len(common_issue_ids) > 2: + for_lookup_common_issue_ids = common_issue_ids[:-1] + meta = self.jira.createmeta( + projectIds=(proja.id, projb.id), issuetypeIds=for_lookup_common_issue_ids + ) + self.assertEqual(len(meta["projects"]), 2) + for project in meta["projects"]: + self.assertEqual( + len(project["issuetypes"]), len(for_lookup_common_issue_ids) + ) + + def test_createmeta_expand(self): + # limit to SCR project so the call returns promptly + meta = self.jira.createmeta( + projectKeys=self.project_b, expand="projects.issuetypes.fields" + ) + self.assertTrue("fields" in meta["projects"][0]["issuetypes"][0]) + + def test_assign_issue(self): + self.assertTrue(self.jira.assign_issue(self.issue_1, self.user_normal.name)) + self.assertEqual( + self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name + ) + + def test_assign_issue_with_issue_obj(self): + issue = self.jira.issue(self.issue_1) + x = self.jira.assign_issue(issue, self.user_normal.name) + self.assertTrue(x) + self.assertEqual( + self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name + ) + + def test_assign_to_bad_issue_raises(self): + self.assertRaises(JIRAError, self.jira.assign_issue, "NOPE-1", "notauser") + + def test_editmeta(self): + expected_fields = { + "assignee", + "attachment", + "comment", + "components", + "description", + "fixVersions", + "issuelinks", + "labels", + "summary", + } + for i in (self.issue_1, self.issue_2): + meta = self.jira.editmeta(i) + meta_field_set = set(meta["fields"].keys()) + self.assertEqual( + meta_field_set.intersection(expected_fields), expected_fields + ) + + def test_transitioning(self): + # we check with both issue-as-string or issue-as-object + transitions = [] + for issue in [self.issue_2, self.jira.issue(self.issue_2)]: + transitions = self.jira.transitions(issue) + self.assertTrue(transitions) + self.assertTrue("id" in transitions[0]) + self.assertTrue("name" in transitions[0]) + + self.assertTrue(transitions, msg="Expecting at least one transition") + # we test getting a single transition + transition = self.jira.transitions(self.issue_2, transitions[0]["id"])[0] + self.assertDictEqual(transition, transitions[0]) + + # we test the expand of fields + transition = self.jira.transitions( + self.issue_2, transitions[0]["id"], expand="transitions.fields" + )[0] + self.assertTrue("fields" in transition) + + # Testing of transition with field assignment is disabled now because default workflows do not have it. + + # self.jira.transition_issue(issue, transitions[0]['id'], assignee={'name': self.test_manager.CI_JIRA_ADMIN}) + # issue = self.jira.issue(issue.key) + # self.assertEqual(issue.fields.assignee.name, self.test_manager.CI_JIRA_ADMIN) + # + # fields = { + # 'assignee': { + # 'name': self.test_manager.CI_JIRA_USER + # } + # } + # transitions = self.jira.transitions(issue.key) + # self.assertTrue(transitions) # any issue should have at least one transition available to it + # transition_id = transitions[0]['id'] + # + # self.jira.transition_issue(issue.key, transition_id, fields=fields) + # issue = self.jira.issue(issue.key) + # self.assertEqual(issue.fields.assignee.name, self.test_manager.CI_JIRA_USER) + # self.assertEqual(issue.fields.status.id, transition_id) + + @not_on_custom_jira_instance + def test_agile(self): + uniq = rndstr() + board_name = "board-" + uniq + sprint_name = "sprint-" + uniq + + b = self.jira.create_board(board_name, self.project_a) + assert isinstance(b.id, int) + + s = self.jira.create_sprint(sprint_name, b.id) + assert isinstance(s.id, int) + assert s.name == sprint_name + assert s.state == "FUTURE" + + self.jira.add_issues_to_sprint(s.id, [self.issue_1]) + + sprint_field_name = "Sprint" + sprint_field_id = [ + f["schema"]["customId"] + for f in self.jira.fields() + if f["name"] == sprint_field_name + ][0] + sprint_customfield = "customfield_" + str(sprint_field_id) + + updated_issue_1 = self.jira.issue(self.issue_1) + serialised_sprint = getattr(updated_issue_1.fields, sprint_customfield)[0] + + # Too hard to serialise the sprint object. Performing simple regex match instead. + assert re.search(r"\[id=" + str(s.id) + ",", serialised_sprint) + + # self.jira.add_issues_to_sprint(s.id, self.issue_2) + + # self.jira.rank(self.issue_2, self.issue_1) + + sleep(2) # avoid https://travis-ci.org/pycontribs/jira/jobs/176561534#L516 + s.delete() + + sleep(2) + b.delete() + # self.jira.delete_board(b.id) diff --git a/tests/resources/test_issue_link.py b/tests/resources/test_issue_link.py new file mode 100644 index 000000000..82aee6799 --- /dev/null +++ b/tests/resources/test_issue_link.py @@ -0,0 +1,41 @@ +from tests.conftest import JiraTestCase + + +class IssueLinkTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.link_types = self.test_manager.jira_admin.issue_link_types() + + def test_issue_link(self): + self.link = self.test_manager.jira_admin.issue_link_type(self.link_types[0].id) + link = self.link # Duplicate outward + self.assertEqual(link.id, self.link_types[0].id) + + def test_create_issue_link(self): + self.test_manager.jira_admin.create_issue_link( + self.link_types[0].outward, + self.test_manager.project_b_issue1, + self.test_manager.project_b_issue2, + ) + + def test_create_issue_link_with_issue_obj(self): + inwardissue = self.test_manager.jira_admin.issue( + self.test_manager.project_b_issue1 + ) + self.assertIsNotNone(inwardissue) + outwardissue = self.test_manager.jira_admin.issue( + self.test_manager.project_b_issue2 + ) + self.assertIsNotNone(outwardissue) + self.test_manager.jira_admin.create_issue_link( + self.link_types[0].outward, inwardissue, outwardissue + ) + + # @unittest.skip("Creating an issue link doesn't return its ID, so can't easily test delete") + # def test_delete_issue_link(self): + # pass + + def test_issue_link_type(self): + link_type = self.test_manager.jira_admin.issue_link_type(self.link_types[0].id) + self.assertEqual(link_type.id, self.link_types[0].id) + self.assertEqual(link_type.name, self.link_types[0].name) diff --git a/tests/resources/test_issue_link_type.py b/tests/resources/test_issue_link_type.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/test_priority.py b/tests/resources/test_priority.py new file mode 100644 index 000000000..032041fe3 --- /dev/null +++ b/tests/resources/test_priority.py @@ -0,0 +1,13 @@ +from tests.conftest import JiraTestCase, not_on_custom_jira_instance + + +class PrioritiesTests(JiraTestCase): + def test_priorities(self): + priorities = self.jira.priorities() + self.assertEqual(len(priorities), 5) + + @not_on_custom_jira_instance + def test_priority(self): + priority = self.jira.priority("2") + self.assertEqual(priority.id, "2") + self.assertEqual(priority.name, "Critical") diff --git a/tests/resources/test_project.py b/tests/resources/test_project.py new file mode 100644 index 000000000..05f4e4a82 --- /dev/null +++ b/tests/resources/test_project.py @@ -0,0 +1,205 @@ +from tests.conftest import JiraTestCase, broken_test, find_by_id, rndstr + + +class ProjectTests(JiraTestCase): + def test_projects(self): + projects = self.jira.projects() + self.assertGreaterEqual(len(projects), 2) + + def test_project(self): + project = self.jira.project(self.project_b) + self.assertEqual(project.key, self.project_b) + + def test_project_expand(self): + project = self.jira.project(self.project_b) + self.assertFalse(hasattr(project, "projectKeys")) + project = self.jira.project(self.project_b, expand="projectKeys") + self.assertTrue(hasattr(project, "projectKeys")) + + def test_projects_expand(self): + projects = self.jira.projects() + for project in projects: + self.assertFalse(hasattr(project, "projectKeys")) + projects = self.jira.projects(expand="projectKeys") + for project in projects: + self.assertTrue(hasattr(project, "projectKeys")) + + # I have no idea what avatars['custom'] is and I get different results every time + # def test_project_avatars(self): + # avatars = self.jira.project_avatars(self.project_b) + # self.assertEqual(len(avatars['custom']), 3) + # self.assertEqual(len(avatars['system']), 16) + # + # def test_project_avatars_with_project_obj(self): + # project = self.jira.project(self.project_b) + # avatars = self.jira.project_avatars(project) + # self.assertEqual(len(avatars['custom']), 3) + # self.assertEqual(len(avatars['system']), 16) + + # def test_create_project_avatar(self): + # Tests the end-to-end project avatar creation process: upload as temporary, confirm after cropping, + # and selection. + # project = self.jira.project(self.project_b) + # size = os.path.getsize(TEST_ICON_PATH) + # filename = os.path.basename(TEST_ICON_PATH) + # with open(TEST_ICON_PATH, "rb") as icon: + # props = self.jira.create_temp_project_avatar(project, filename, size, icon.read()) + # self.assertIn('cropperOffsetX', props) + # self.assertIn('cropperOffsetY', props) + # self.assertIn('cropperWidth', props) + # self.assertTrue(props['needsCropping']) + # + # props['needsCropping'] = False + # avatar_props = self.jira.confirm_project_avatar(project, props) + # self.assertIn('id', avatar_props) + # + # self.jira.set_project_avatar(self.project_b, avatar_props['id']) + # + # def test_delete_project_avatar(self): + # size = os.path.getsize(TEST_ICON_PATH) + # filename = os.path.basename(TEST_ICON_PATH) + # with open(TEST_ICON_PATH, "rb") as icon: + # props = self.jira.create_temp_project_avatar(self.project_b, filename, size, icon.read(), auto_confirm=True) + # self.jira.delete_project_avatar(self.project_b, props['id']) + # + # def test_delete_project_avatar_with_project_obj(self): + # project = self.jira.project(self.project_b) + # size = os.path.getsize(TEST_ICON_PATH) + # filename = os.path.basename(TEST_ICON_PATH) + # with open(TEST_ICON_PATH, "rb") as icon: + # props = self.jira.create_temp_project_avatar(project, filename, size, icon.read(), auto_confirm=True) + # self.jira.delete_project_avatar(project, props['id']) + + # @pytest.mark.xfail(reason="Jira may return 500") + # def test_set_project_avatar(self): + # def find_selected_avatar(avatars): + # for avatar in avatars['system']: + # if avatar['isSelected']: + # return avatar + # else: + # raise Exception + # + # self.jira.set_project_avatar(self.project_b, '10001') + # avatars = self.jira.project_avatars(self.project_b) + # self.assertEqual(find_selected_avatar(avatars)['id'], '10001') + # + # project = self.jira.project(self.project_b) + # self.jira.set_project_avatar(project, '10208') + # avatars = self.jira.project_avatars(project) + # self.assertEqual(find_selected_avatar(avatars)['id'], '10208') + + def test_project_components(self): + proj = self.jira.project(self.project_b) + name = "component-%s from project %s" % (proj, rndstr()) + component = self.jira.create_component( + name, + proj, + description="test!!", + assigneeType="COMPONENT_LEAD", + isAssigneeTypeValid=False, + ) + components = self.jira.project_components(self.project_b) + self.assertGreaterEqual(len(components), 1) + sample = find_by_id(components, component.id) + self.assertEqual(sample.id, component.id) + self.assertEqual(sample.name, name) + component.delete() + + def test_project_versions(self): + name = "version-%s" % rndstr() + version = self.jira.create_version(name, self.project_b, "will be deleted soon") + versions = self.jira.project_versions(self.project_b) + self.assertGreaterEqual(len(versions), 1) + test = find_by_id(versions, version.id) + self.assertEqual(test.id, version.id) + self.assertEqual(test.name, name) + + i = self.jira.issue(self.test_manager.project_b_issue1) + i.update(fields={"fixVersions": [{"id": version.id}]}) + version.delete() + + def test_update_project_version(self): + # given + name = "version-%s" % rndstr() + version = self.jira.create_version(name, self.project_b, "will be deleted soon") + updated_name = "version-%s" % rndstr() + # when + version.update(name=updated_name) + # then + self.assertEqual(updated_name, version.name) + version.delete() + + def test_get_project_version_by_name(self): + name = "version-%s" % rndstr() + version = self.jira.create_version(name, self.project_b, "will be deleted soon") + + found_version = self.jira.get_project_version_by_name(self.project_b, name) + self.assertEqual(found_version.id, version.id) + self.assertEqual(found_version.name, name) + + not_found_version = self.jira.get_project_version_by_name( + self.project_b, "non-existent" + ) + self.assertEqual(not_found_version, None) + + i = self.jira.issue(self.test_manager.project_b_issue1) + i.update(fields={"fixVersions": [{"id": version.id}]}) + version.delete() + + def test_rename_version(self): + old_name = "version-%s" % rndstr() + version = self.jira.create_version( + old_name, self.project_b, "will be deleted soon" + ) + + new_name = old_name + "-renamed" + self.jira.rename_version(self.project_b, old_name, new_name) + + found_version = self.jira.get_project_version_by_name(self.project_b, new_name) + self.assertEqual(found_version.id, version.id) + self.assertEqual(found_version.name, new_name) + + not_found_version = self.jira.get_project_version_by_name( + self.project_b, old_name + ) + self.assertEqual(not_found_version, None) + + i = self.jira.issue(self.test_manager.project_b_issue1) + i.update(fields={"fixVersions": [{"id": version.id}]}) + version.delete() + + def test_project_versions_with_project_obj(self): + name = "version-%s" % rndstr() + version = self.jira.create_version(name, self.project_b, "will be deleted soon") + project = self.jira.project(self.project_b) + versions = self.jira.project_versions(project) + self.assertGreaterEqual(len(versions), 1) + test = find_by_id(versions, version.id) + self.assertEqual(test.id, version.id) + self.assertEqual(test.name, name) + version.delete() + + @broken_test( + reason="temporary disabled because roles() return a dictionary of role_name:role_url and we have no call to convert it to proper Role()" + ) + def test_project_roles(self): + project = self.jira.project(self.project_b) + role_name = "Developers" + dev = None + for roles in [ + self.jira.project_roles(self.project_b), + self.jira.project_roles(project), + ]: + self.assertGreaterEqual(len(roles), 5) + self.assertIn("Users", roles) + self.assertIn(role_name, roles) + dev = roles[role_name] + self.assertTrue(dev) + role = self.jira.project_role(self.project_b, dev.id) + self.assertEqual(role.id, dev.id) + self.assertEqual(role.name, dev.name) + user = self.test_manager.jira_admin + self.assertNotIn(user, role.actors) + role.update(users=user, groups=["jira-developers", "jira-users"]) + role = self.jira.project_role(self.project_b, dev.id) + self.assertIn(user, role.actors) diff --git a/tests/resources/test_remote_link.py b/tests/resources/test_remote_link.py new file mode 100644 index 000000000..3a89eb07f --- /dev/null +++ b/tests/resources/test_remote_link.py @@ -0,0 +1,119 @@ +from jira.exceptions import JIRAError +from tests.conftest import JiraTestCase, broken_test + + +@broken_test(reason="Nothing from remote link works") +class RemoteLinkTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + self.issue_2 = self.test_manager.project_b_issue2 + self.issue_3 = self.test_manager.project_b_issue3 + + def test_remote_links(self): + self.jira.add_remote_link( + "ZTRAVISDEB-3", globalId="python-test:story.of.horse.riding" + ) + links = self.jira.remote_links("QA-44") + self.assertEqual(len(links), 1) + links = self.jira.remote_links("BULK-1") + self.assertEqual(len(links), 0) + + @broken_test(reason="temporary disabled") + def test_remote_links_with_issue_obj(self): + issue = self.jira.issue("QA-44") + links = self.jira.remote_links(issue) + self.assertEqual(len(links), 1) + issue = self.jira.issue("BULK-1") + links = self.jira.remote_links(issue) + self.assertEqual(len(links), 0) + + @broken_test(reason="temporary disabled") + def test_remote_link(self): + link = self.jira.remote_link("QA-44", "10000") + self.assertEqual(link.id, 10000) + self.assertTrue(hasattr(link, "globalId")) + self.assertTrue(hasattr(link, "relationship")) + + @broken_test(reason="temporary disabled") + def test_remote_link_with_issue_obj(self): + issue = self.jira.issue("QA-44") + link = self.jira.remote_link(issue, "10000") + self.assertEqual(link.id, 10000) + self.assertTrue(hasattr(link, "globalId")) + self.assertTrue(hasattr(link, "relationship")) + + @broken_test(reason="temporary disabled") + def test_add_remote_link(self): + link = self.jira.add_remote_link( + "BULK-3", + globalId="python-test:story.of.horse.riding", + object={"url": "http://google.com", "title": "googlicious!"}, + application={"name": "far too silly", "type": "sketch"}, + relationship="mousebending", + ) + # creation response doesn't include full remote link info, + # so we fetch it again using the new internal ID + link = self.jira.remote_link("BULK-3", link.id) + self.assertEqual(link.application.name, "far too silly") + self.assertEqual(link.application.type, "sketch") + self.assertEqual(link.object.url, "http://google.com") + self.assertEqual(link.object.title, "googlicious!") + self.assertEqual(link.relationship, "mousebending") + self.assertEqual(link.globalId, "python-test:story.of.horse.riding") + + @broken_test(reason="temporary disabled") + def test_add_remote_link_with_issue_obj(self): + issue = self.jira.issue("BULK-3") + link = self.jira.add_remote_link( + issue, + globalId="python-test:story.of.horse.riding", + object={"url": "http://google.com", "title": "googlicious!"}, + application={"name": "far too silly", "type": "sketch"}, + relationship="mousebending", + ) + # creation response doesn't include full remote link info, + # so we fetch it again using the new internal ID + link = self.jira.remote_link(issue, link.id) + self.assertEqual(link.application.name, "far too silly") + self.assertEqual(link.application.type, "sketch") + self.assertEqual(link.object.url, "http://google.com") + self.assertEqual(link.object.title, "googlicious!") + self.assertEqual(link.relationship, "mousebending") + self.assertEqual(link.globalId, "python-test:story.of.horse.riding") + + @broken_test(reason="temporary disabled") + def test_update_remote_link(self): + link = self.jira.add_remote_link( + "BULK-3", + globalId="python-test:story.of.horse.riding", + object={"url": "http://google.com", "title": "googlicious!"}, + application={"name": "far too silly", "type": "sketch"}, + relationship="mousebending", + ) + # creation response doesn't include full remote link info, + # so we fetch it again using the new internal ID + link = self.jira.remote_link("BULK-3", link.id) + link.update( + object={"url": "http://yahoo.com", "title": "yahoo stuff"}, + globalId="python-test:updated.id", + relationship="cheesing", + ) + self.assertEqual(link.globalId, "python-test:updated.id") + self.assertEqual(link.relationship, "cheesing") + self.assertEqual(link.object.url, "http://yahoo.com") + self.assertEqual(link.object.title, "yahoo stuff") + link.delete() + + @broken_test(reason="temporary disabled") + def test_delete_remove_link(self): + link = self.jira.add_remote_link( + "BULK-3", + globalId="python-test:story.of.horse.riding", + object={"url": "http://google.com", "title": "googlicious!"}, + application={"name": "far too silly", "type": "sketch"}, + relationship="mousebending", + ) + _id = link.id + link.delete() + self.assertRaises(JIRAError, self.jira.remote_link, "BULK-3", _id) diff --git a/tests/resources/test_request_type.py b/tests/resources/test_request_type.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/test_resolution.py b/tests/resources/test_resolution.py new file mode 100644 index 000000000..bb6beda49 --- /dev/null +++ b/tests/resources/test_resolution.py @@ -0,0 +1,13 @@ +from tests.conftest import JiraTestCase, not_on_custom_jira_instance + + +@not_on_custom_jira_instance +class ResolutionTests(JiraTestCase): + def test_resolutions(self): + resolutions = self.jira.resolutions() + self.assertGreaterEqual(len(resolutions), 1) + + def test_resolution(self): + resolution = self.jira.resolution("2") + self.assertEqual(resolution.id, "2") + self.assertEqual(resolution.name, "Won't Fix") diff --git a/tests/resources/test_role.py b/tests/resources/test_role.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/test_security_level.py b/tests/resources/test_security_level.py new file mode 100644 index 000000000..b3b3090bf --- /dev/null +++ b/tests/resources/test_security_level.py @@ -0,0 +1,9 @@ +from tests.conftest import JiraTestCase, broken_test + + +@broken_test(reason="Skipped due to https://jira.atlassian.com/browse/JRA-59619") +class SecurityLevelTests(JiraTestCase): + def test_security_level(self): + # This is hardcoded due to Atlassian bug: https://jira.atlassian.com/browse/JRA-59619 + sec_level = self.jira.security_level("10000") + self.assertEqual(sec_level.id, "10000") diff --git a/tests/resources/test_service_desk.py b/tests/resources/test_service_desk.py new file mode 100644 index 000000000..b1d407f13 --- /dev/null +++ b/tests/resources/test_service_desk.py @@ -0,0 +1,60 @@ +import logging +from time import sleep + +import pytest + +from tests.conftest import JiraTestCase, broken_test + +LOGGER = logging.getLogger(__name__) + + +class JiraServiceDeskTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + if not self.jira.supports_service_desk(): + pytest.skip("Skipping Service Desk not enabled") + + try: + self.jira.delete_project(self.test_manager.project_sd) + except Exception: + LOGGER.warning("Failed to delete %s", self.test_manager.project_sd) + + @broken_test(reason="Broken needs fixing") + def test_create_customer_request(self): + + self.jira.create_project( + key=self.test_manager.project_sd, + name=self.test_manager.project_sd_name, + ptype="service_desk", + template_name="IT Service Desk", + ) + service_desks = [] + for _ in range(3): + service_desks = self.jira.service_desks() + if service_desks: + break + logging.warning("Service desk not reported...") + sleep(2) + self.assertTrue(service_desks, "No service desks were found!") + service_desk = service_desks[0] + + for _ in range(3): + request_types = self.jira.request_types(service_desk) + if request_types: + logging.warning("Service desk request_types not reported...") + break + sleep(2) + self.assertTrue(request_types, "No request_types for service desk found!") + + request = self.jira.create_customer_request( + dict( + serviceDeskId=service_desk.id, + requestTypeId=int(request_types[0].id), + requestFieldValues=dict( + summary="Ticket title here", description="Ticket body here" + ), + ) + ) + + self.assertEqual(request.fields.summary, "Ticket title here") + self.assertEqual(request.fields.description, "Ticket body here") diff --git a/tests/resources/test_sprint.py b/tests/resources/test_sprint.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/test_status.py b/tests/resources/test_status.py new file mode 100644 index 000000000..e497ace96 --- /dev/null +++ b/tests/resources/test_status.py @@ -0,0 +1,16 @@ +from tests.conftest import JiraTestCase + + +class StatusTests(JiraTestCase): + def test_statuses(self): + found = False + statuses = self.jira.statuses() + for status in statuses: + if status.name == "Done": + found = True + # find status + s = self.jira.status(status.id) + self.assertEqual(s.id, status.id) + break + self.assertTrue(found, "Status Done not found. [%s]" % statuses) + self.assertGreater(len(statuses), 0) diff --git a/tests/resources/test_status_category.py b/tests/resources/test_status_category.py new file mode 100644 index 000000000..c204b6e13 --- /dev/null +++ b/tests/resources/test_status_category.py @@ -0,0 +1,20 @@ +from tests.conftest import JiraTestCase + + +class StatusCategoryTests(JiraTestCase): + def test_statuscategories(self): + found = False + statuscategories = self.jira.statuscategories() + for statuscategory in statuscategories: + if statuscategory.id == 1 and statuscategory.name == u"No Category": + found = True + break + self.assertTrue( + found, "StatusCategory with id=1 not found. [%s]" % statuscategories + ) + self.assertGreater(len(statuscategories), 0) + + def test_statuscategory(self): + statuscategory = self.jira.statuscategory(1) + self.assertEqual(statuscategory.id, 1) + self.assertEqual(statuscategory.name, "No Category") diff --git a/tests/resources/test_user.py b/tests/resources/test_user.py new file mode 100644 index 000000000..db4e465b7 --- /dev/null +++ b/tests/resources/test_user.py @@ -0,0 +1,185 @@ +import os + +from tests.conftest import ( + TEST_ICON_PATH, + JiraTestCase, + broken_test, + not_on_custom_jira_instance, +) + + +class UserTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue = self.test_manager.project_b_issue3 + + def test_user(self): + user = self.jira.user(self.test_manager.user_admin.name) + self.assertTrue(user.name) + self.assertRegex( + user.emailAddress, r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$" + ) + + def test_search_assignable_users_for_projects(self): + users = self.jira.search_assignable_users_for_projects( + self.test_manager.CI_JIRA_ADMIN, "%s,%s" % (self.project_a, self.project_b) + ) + self.assertGreaterEqual(len(users), 1) + usernames = map(lambda user: user.name, users) + self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) + + def test_search_assignable_users_for_projects_maxresults(self): + users = self.jira.search_assignable_users_for_projects( + self.test_manager.CI_JIRA_ADMIN, + "%s,%s" % (self.project_a, self.project_b), + maxResults=1, + ) + self.assertLessEqual(len(users), 1) + + def test_search_assignable_users_for_projects_startat(self): + users = self.jira.search_assignable_users_for_projects( + self.test_manager.CI_JIRA_ADMIN, + "%s,%s" % (self.project_a, self.project_b), + startAt=1, + ) + self.assertGreaterEqual(len(users), 0) + + @not_on_custom_jira_instance + def test_search_assignable_users_for_issues_by_project(self): + users = self.jira.search_assignable_users_for_issues( + self.test_manager.CI_JIRA_ADMIN, project=self.project_b + ) + self.assertEqual(len(users), 1) + usernames = map(lambda user: user.name, users) + self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) + + def test_search_assignable_users_for_issues_by_project_maxresults(self): + users = self.jira.search_assignable_users_for_issues( + self.test_manager.CI_JIRA_USER, project=self.project_b, maxResults=1 + ) + self.assertLessEqual(len(users), 1) + + def test_search_assignable_users_for_issues_by_project_startat(self): + users = self.jira.search_assignable_users_for_issues( + self.test_manager.CI_JIRA_USER, project=self.project_a, startAt=1 + ) + self.assertGreaterEqual(len(users), 0) + + @not_on_custom_jira_instance + def test_search_assignable_users_for_issues_by_issue(self): + users = self.jira.search_assignable_users_for_issues( + self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue + ) + self.assertEqual(len(users), 1) + usernames = map(lambda user: user.name, users) + self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) + + def test_search_assignable_users_for_issues_by_issue_maxresults(self): + users = self.jira.search_assignable_users_for_issues( + self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue, maxResults=2 + ) + self.assertLessEqual(len(users), 2) + + def test_search_assignable_users_for_issues_by_issue_startat(self): + users = self.jira.search_assignable_users_for_issues( + self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue, startAt=2 + ) + self.assertGreaterEqual(len(users), 0) + + @broken_test(reason="Jira may return 500") + def test_user_avatars(self): + # Tests the end-to-end user avatar creation process: upload as temporary, confirm after cropping, + # and selection. + size = os.path.getsize(TEST_ICON_PATH) + # filename = os.path.basename(TEST_ICON_PATH) + with open(TEST_ICON_PATH, "rb") as icon: + props = self.jira.create_temp_user_avatar( + self.test_manager.CI_JIRA_ADMIN, TEST_ICON_PATH, size, icon.read() + ) + self.assertIn("cropperOffsetX", props) + self.assertIn("cropperOffsetY", props) + self.assertIn("cropperWidth", props) + self.assertTrue(props["needsCropping"]) + + props["needsCropping"] = False + avatar_props = self.jira.confirm_user_avatar( + self.test_manager.CI_JIRA_ADMIN, props + ) + self.assertIn("id", avatar_props) + self.assertEqual(avatar_props["owner"], self.test_manager.CI_JIRA_ADMIN) + + self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatar_props["id"]) + + avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) + self.assertGreaterEqual( + len(avatars["system"]), 20 + ) # observed values between 20-24 so far + self.assertGreaterEqual(len(avatars["custom"]), 1) + + @broken_test(reason="broken: set avatar returns 400") + def test_set_user_avatar(self): + def find_selected_avatar(avatars): + for avatar in avatars["system"]: + if avatar["isSelected"]: + return avatar + # else: + # raise Exception as e + # print(e) + + avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) + + self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatars["system"][0]) + avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) + self.assertEqual(find_selected_avatar(avatars)["id"], avatars["system"][0]) + + self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatars["system"][1]) + avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) + self.assertEqual(find_selected_avatar(avatars)["id"], avatars["system"][1]) + + # WRONG + @broken_test(reason="disable until I have permissions to write/modify") + def test_delete_user_avatar(self): + size = os.path.getsize(TEST_ICON_PATH) + filename = os.path.basename(TEST_ICON_PATH) + with open(TEST_ICON_PATH, "rb") as icon: + props = self.jira.create_temp_user_avatar( + self.test_manager.CI_JIRA_ADMIN, filename, size, icon.read() + ) + self.jira.delete_user_avatar(self.test_manager.CI_JIRA_ADMIN, props["id"]) + + def test_search_users(self): + users = self.jira.search_users(self.test_manager.CI_JIRA_ADMIN) + self.assertGreaterEqual(len(users), 1) + usernames = map(lambda user: user.name, users) + self.assertIn(self.test_manager.user_admin.name, usernames) + + def test_search_users_maxresults(self): + users = self.jira.search_users(self.test_manager.CI_JIRA_USER, maxResults=1) + self.assertGreaterEqual(1, len(users)) + + def test_search_allowed_users_for_issue_by_project(self): + users = self.jira.search_allowed_users_for_issue( + self.test_manager.CI_JIRA_USER, projectKey=self.project_a + ) + self.assertGreaterEqual(len(users), 1) + + @not_on_custom_jira_instance + def test_search_allowed_users_for_issue_by_issue(self): + users = self.jira.search_allowed_users_for_issue("a", issueKey=self.issue) + self.assertGreaterEqual(len(users), 1) + + def test_search_allowed_users_for_issue_maxresults(self): + users = self.jira.search_allowed_users_for_issue( + "a", projectKey=self.project_b, maxResults=2 + ) + self.assertLessEqual(len(users), 2) + + def test_search_allowed_users_for_issue_startat(self): + users = self.jira.search_allowed_users_for_issue( + "c", projectKey=self.project_b, startAt=1 + ) + self.assertGreaterEqual(len(users), 0) + + def test_add_users_to_set(self): + users_set = set([self.test_manager.user_admin, self.test_manager.user_admin]) + self.assertEqual(len(users_set), 1) diff --git a/tests/resources/test_version.py b/tests/resources/test_version.py new file mode 100644 index 000000000..b4af6b388 --- /dev/null +++ b/tests/resources/test_version.py @@ -0,0 +1,58 @@ +from jira.exceptions import JIRAError +from tests.conftest import JiraTestCase + + +class VersionTests(JiraTestCase): + def test_create_version(self): + name = "new version " + self.project_b + desc = "test version of " + self.project_b + release_date = "2015-03-11" + version = self.jira.create_version( + name, self.project_b, releaseDate=release_date, description=desc + ) + self.assertEqual(version.name, name) + self.assertEqual(version.description, desc) + self.assertEqual(version.releaseDate, release_date) + version.delete() + + def test_create_version_with_project_obj(self): + project = self.jira.project(self.project_b) + version = self.jira.create_version( + "new version 2", + project, + releaseDate="2015-03-11", + description="test version!", + ) + self.assertEqual(version.name, "new version 2") + self.assertEqual(version.description, "test version!") + self.assertEqual(version.releaseDate, "2015-03-11") + version.delete() + + def test_update_version(self): + + version = self.jira.create_version( + "new updated version 1", + self.project_b, + releaseDate="2015-03-11", + description="new to be updated!", + ) + version.update(name="new updated version name 1", description="new updated!") + self.assertEqual(version.name, "new updated version name 1") + self.assertEqual(version.description, "new updated!") + + v = self.jira.version(version.id) + self.assertEqual(v, version) + self.assertEqual(v.id, version.id) + + version.delete() + + def test_delete_version(self): + version_str = "test_delete_version:" + self.test_manager.jid + version = self.jira.create_version( + version_str, + self.project_b, + releaseDate="2015-03-11", + description="not long for this world", + ) + version.delete() + self.assertRaises(JIRAError, self.jira.version, version.id) diff --git a/tests/resources/test_vote.py b/tests/resources/test_vote.py new file mode 100644 index 000000000..d3d6451ef --- /dev/null +++ b/tests/resources/test_vote.py @@ -0,0 +1,36 @@ +from tests.conftest import JiraTestCase + + +class VoteTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + + def test_votes(self): + self.jira_normal.remove_vote(self.issue_1) + # not checking the result on this + votes = self.jira.votes(self.issue_1) + self.assertEqual(votes.votes, 0) + + self.jira_normal.add_vote(self.issue_1) + new_votes = self.jira.votes(self.issue_1) + assert votes.votes + 1 == new_votes.votes + + self.jira_normal.remove_vote(self.issue_1) + new_votes = self.jira.votes(self.issue_1) + assert votes.votes == new_votes.votes + + def test_votes_with_issue_obj(self): + issue = self.jira_normal.issue(self.issue_1) + self.jira_normal.remove_vote(issue) + # not checking the result on this + votes = self.jira.votes(issue) + self.assertEqual(votes.votes, 0) + + self.jira_normal.add_vote(issue) + new_votes = self.jira.votes(issue) + assert votes.votes + 1 == new_votes.votes + + self.jira_normal.remove_vote(issue) + new_votes = self.jira.votes(issue) + assert votes.votes == new_votes.votes diff --git a/tests/resources/test_watchers.py b/tests/resources/test_watchers.py new file mode 100644 index 000000000..a8137adbf --- /dev/null +++ b/tests/resources/test_watchers.py @@ -0,0 +1,22 @@ +from tests.conftest import JiraTestCase + + +class WatchersTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + + def test_add_remove_watcher(self): + + # removing it in case it exists, so we know its state + self.jira.remove_watcher(self.issue_1, self.test_manager.user_normal.name) + init_watchers = self.jira.watchers(self.issue_1).watchCount + + # adding a new watcher + self.jira.add_watcher(self.issue_1, self.test_manager.user_normal.name) + self.assertEqual(self.jira.watchers(self.issue_1).watchCount, init_watchers + 1) + + # now we verify that remove does indeed remove watchers + self.jira.remove_watcher(self.issue_1, self.test_manager.user_normal.name) + new_watchers = self.jira.watchers(self.issue_1).watchCount + self.assertEqual(init_watchers, new_watchers) diff --git a/tests/resources/test_worklog.py b/tests/resources/test_worklog.py new file mode 100644 index 000000000..642539094 --- /dev/null +++ b/tests/resources/test_worklog.py @@ -0,0 +1,65 @@ +from tests.conftest import JiraTestCase + + +class WorklogTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1 = self.test_manager.project_b_issue1 + self.issue_2 = self.test_manager.project_b_issue2 + self.issue_3 = self.test_manager.project_b_issue3 + + def test_worklogs(self): + worklog = self.jira.add_worklog(self.issue_1, "2h") + worklogs = self.jira.worklogs(self.issue_1) + self.assertEqual(len(worklogs), 1) + worklog.delete() + + def test_worklogs_with_issue_obj(self): + issue = self.jira.issue(self.issue_1) + worklog = self.jira.add_worklog(issue, "2h") + worklogs = self.jira.worklogs(issue) + self.assertEqual(len(worklogs), 1) + worklog.delete() + + def test_worklog(self): + worklog = self.jira.add_worklog(self.issue_1, "1d 2h") + new_worklog = self.jira.worklog(self.issue_1, str(worklog)) + self.assertEqual(new_worklog.author.name, self.test_manager.user_admin.name) + self.assertEqual(new_worklog.timeSpent, "1d 2h") + worklog.delete() + + def test_worklog_with_issue_obj(self): + issue = self.jira.issue(self.issue_1) + worklog = self.jira.add_worklog(issue, "1d 2h") + new_worklog = self.jira.worklog(issue, str(worklog)) + self.assertEqual(new_worklog.author.name, self.test_manager.user_admin.name) + self.assertEqual(new_worklog.timeSpent, "1d 2h") + worklog.delete() + + def test_add_worklog(self): + worklog_count = len(self.jira.worklogs(self.issue_2)) + worklog = self.jira.add_worklog(self.issue_2, "2h") + self.assertIsNotNone(worklog) + self.assertEqual(len(self.jira.worklogs(self.issue_2)), worklog_count + 1) + worklog.delete() + + def test_add_worklog_with_issue_obj(self): + issue = self.jira.issue(self.issue_2) + worklog_count = len(self.jira.worklogs(issue)) + worklog = self.jira.add_worklog(issue, "2h") + self.assertIsNotNone(worklog) + self.assertEqual(len(self.jira.worklogs(issue)), worklog_count + 1) + worklog.delete() + + def test_update_and_delete_worklog(self): + worklog = self.jira.add_worklog(self.issue_3, "3h") + issue = self.jira.issue(self.issue_3, fields="worklog,timetracking") + worklog.update(comment="Updated!", timeSpent="2h") + self.assertEqual(worklog.comment, "Updated!") + # rem_estimate = issue.fields.timetracking.remainingEstimate + self.assertEqual(worklog.timeSpent, "2h") + issue = self.jira.issue(self.issue_3, fields="worklog,timetracking") + self.assertEqual(issue.fields.timetracking.remainingEstimate, "1h") + worklog.delete() + issue = self.jira.issue(self.issue_3, fields="worklog,timetracking") + self.assertEqual(issue.fields.timetracking.remainingEstimate, "3h") diff --git a/tests/test_client.py b/tests/test_client.py index 6a8a36716..a717d190b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,11 +4,11 @@ import pytest import jira.client -from jira import JIRA, Issue, JIRAError, Project, Role # noqa +from jira.exceptions import JIRAError +from tests.conftest import JiraTestManager, get_unique_project_name # from tenacity import retry # from tenacity import wait_incrementing -from tests import JiraTestManager, get_unique_project_name @pytest.fixture() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 000000000..8d0abef9b --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,147 @@ +import unittest +from pathlib import Path +from unittest.mock import mock_open, patch + +from requests import Response +from requests.structures import CaseInsensitiveDict + +from jira.exceptions import JIRAError + +DUMMY_HEADERS = {"h": "nice headers"} +DUMMY_TEXT = "nice text" +DUMMY_URL = "https://nice.jira.tests" +DUMMY_STATUS_CODE = 200 + +PATCH_BASE = "jira.exceptions" + + +class ExceptionsTests(unittest.TestCase): + class MockResponse(Response, object): + def __init__( + self, + headers: dict = None, + text: str = "", + status_code: int = DUMMY_STATUS_CODE, + url: str = DUMMY_URL, + ): + """Sub optimal but we create a mock response like this.""" + self.headers = CaseInsensitiveDict(headers if headers else {}) + self._text = text + self.status_code = status_code + self.url = url + + @property + def text(self): + return self._text + + @text.setter + def text(self, new_text): + self._text = new_text + + class MalformedMockResponse(object): + def __init__( + self, + headers: dict = None, + text: str = "", + status_code: int = DUMMY_STATUS_CODE, + url: str = DUMMY_URL, + ): + if headers: + self.headers = headers + if text: + self.text = text + self.url = url + self.status_code = status_code + + def test_jira_error_response_added(self): + + err = JIRAError( + response=self.MockResponse(headers=DUMMY_HEADERS, text=DUMMY_TEXT) + ) + err_str = str(err) + + assert f"headers = {DUMMY_HEADERS}" in err_str + assert f"text = {DUMMY_TEXT}" in err_str + + def test_jira_error_malformed_response(self): + # GIVEN: a malformed Response object, without headers or text set + bad_repsonse = self.MalformedMockResponse() + # WHEN: The JiraError's __str__ method is called + err = JIRAError(response=bad_repsonse) + err_str = str(err) + # THEN: there are no errors and neither headers nor text are in the result + assert "headers = " not in err_str + assert "text = " not in err_str + + def test_jira_error_request_added(self): + + err = JIRAError( + request=self.MockResponse(headers=DUMMY_HEADERS, text=DUMMY_TEXT) + ) + err_str = str(err) + + assert f"headers = {DUMMY_HEADERS}" in err_str + assert f"text = {DUMMY_TEXT}" in err_str + + def test_jira_error_malformed_request(self): + # GIVEN: a malformed Response object, without headers or text set + bad_repsonse = self.MalformedMockResponse() + # WHEN: The JiraError's __str__ method is called + err = JIRAError(request=bad_repsonse) + err_str = str(err) + # THEN: there are no errors and neither headers nor text are in the result + assert "headers = " not in err_str + assert "text = " not in err_str + + def test_jira_error_url_added(self): + assert f"url: {DUMMY_URL}" in str(JIRAError(url=DUMMY_URL)) + + def test_jira_error_status_code_added(self): + assert f"JiraError HTTP {DUMMY_STATUS_CODE}" in str( + JIRAError(status_code=DUMMY_STATUS_CODE) + ) + + def test_jira_error_text_added(self): + dummy_text = "wow\tthis\nis\nso cool" + assert f"text: {dummy_text}" in str(JIRAError(text=dummy_text)) + + def test_jira_error_log_to_tempfile_if_env_var_set(self): + # GIVEN: the right env vars are set and the tempfile's filename + env_vars = {"PYJIRA_LOG_TO_TEMPFILE": "so true"} + test_jira_error_filename = ( + Path(__file__).parent / "test_jira_error_log_to_tempfile.bak" + ) + # https://docs.python.org/3/library/unittest.mock.html#mock-open + mocked_open = mock_open() + + # WHEN: a JIRAError's __str__ method is called and + # log details are expected to be sent to the tempfile + with patch.dict("os.environ", env_vars), patch( + f"{PATCH_BASE}.tempfile.mkstemp", autospec=True + ) as mock_mkstemp, patch(f"{PATCH_BASE}.open", mocked_open): + mock_mkstemp.return_value = 0, str(test_jira_error_filename) + str(JIRAError(response=self.MockResponse(text=DUMMY_TEXT))) + + # THEN: the known filename is opened and contains the exception details + mocked_open.assert_called_once_with(str(test_jira_error_filename), "w") + mock_file_stream = mocked_open() + assert f"text = {DUMMY_TEXT}" in mock_file_stream.write.call_args[0][0] + + def test_jira_error_log_to_tempfile_not_used_if_env_var_not_set(self): + # GIVEN: no env vars are set and the tempfile's filename + env_vars = {} + test_jira_error_filename = ( + Path(__file__).parent / "test_jira_error_log_to_tempfile.bak" + ) + # https://docs.python.org/3/library/unittest.mock.html#mock-open + mocked_open = mock_open() + + # WHEN: a JIRAError's __str__ method is called + with patch.dict("os.environ", env_vars), patch( + f"{PATCH_BASE}.tempfile.mkstemp", autospec=True + ) as mock_mkstemp, patch(f"{PATCH_BASE}.open", mocked_open): + mock_mkstemp.return_value = 0, str(test_jira_error_filename) + str(JIRAError(response=self.MockResponse(text=DUMMY_TEXT))) + + # THEN: no files are opened + mocked_open.assert_not_called() diff --git a/tests/test_resilientsession.py b/tests/test_resilientsession.py new file mode 100644 index 000000000..831321cb0 --- /dev/null +++ b/tests/test_resilientsession.py @@ -0,0 +1,55 @@ +import logging + +import jira.resilientsession +from tests.conftest import JiraTestCase + + +class ListLoggingHandler(logging.Handler): + """A logging handler that records all events in a list.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.records = [] + + def emit(self, record): + self.records.append(record) + + def reset(self): + self.records = [] + + +class ResilientSessionLoggingConfidentialityTests(JiraTestCase): + """No sensitive data shall be written to the log.""" + + def setUp(self): + self.loggingHandler = ListLoggingHandler() + jira.resilientsession.logging.getLogger().addHandler(self.loggingHandler) + + def test_logging_with_connection_error(self): + """No sensitive data shall be written to the log in case of a connection error.""" + witness = "etwhpxbhfniqnbbjoqvw" # random string; hopefully unique + for max_retries in (0, 1): + for verb in ("get", "post", "put", "delete", "head", "patch", "options"): + with self.subTest(max_retries=max_retries, verb=verb): + with jira.resilientsession.ResilientSession() as session: + session.max_retries = max_retries + session.max_retry_delay = 0 + try: + getattr(session, verb)( + "http://127.0.0.1:9", + headers={"sensitive_header": witness}, + data={"sensitive_data": witness}, + ) + except jira.resilientsession.ConnectionError: + pass + # check that `witness` does not appear in log + for record in self.loggingHandler.records: + self.assertNotIn(witness, record.msg) + for arg in record.args: + self.assertNotIn(witness, str(arg)) + self.assertNotIn(witness, str(record)) + self.loggingHandler.reset() + + def tearDown(self): + jira.resilientsession.logging.getLogger().removeHandler(self.loggingHandler) + del self.loggingHandler diff --git a/tests/tests.py b/tests/tests.py index b9c8f2291..122d40729 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,343 +1,37 @@ #!/usr/bin/env python -import getpass -import hashlib +"""This file contains tests that do not fit into any specific file yet. + +Feel free to make your own test file if appropriate. + +Refer to conftest.py for shared helper methods. + +resources/test_* : For tests related to resources +test_* : For other tests of the non-resource elements of the jira package. +""" import logging import os import pickle -import random -import re -import string -import sys -import unittest from time import sleep -from typing import Any, Dict +from typing import cast from unittest import mock -import py import pytest import requests -from flaky import flaky - -import jira # noqa -import jira.resilientsession -from jira import JIRA, Issue, JIRAError, Project, Role # noqa -from jira.resources import Group, Resource, UnknownResource, cls_for_resource # noqa - -TEST_ROOT = os.path.dirname(__file__) -TEST_ICON_PATH = os.path.join(TEST_ROOT, "icon.png") -TEST_ATTACH_PATH = os.path.join(TEST_ROOT, "tests.py") - -OAUTH = False -CONSUMER_KEY = "oauth-consumer" -KEY_CERT_FILE = "/home/bspeakmon/src/atlassian-oauth-examples/rsa.pem" -KEY_CERT_DATA = None -try: - with open(KEY_CERT_FILE, "r") as cert: - KEY_CERT_DATA = cert.read() - OAUTH = True -except Exception: - pass - -if "CI_JIRA_URL" in os.environ: - not_on_custom_jira_instance = pytest.mark.skipif( - True, reason="Not applicable for custom Jira instance" - ) - logging.info("Picked up custom Jira engine.") -else: - - def noop(arg): - return arg - - not_on_custom_jira_instance = noop - - -def rndstr(): - return "".join(random.sample(string.ascii_lowercase, 6)) - - -def rndpassword(): - # generates a password of length 14 - s = ( - "".join(random.sample(string.ascii_uppercase, 5)) - + "".join(random.sample(string.ascii_lowercase, 5)) - + "".join(random.sample(string.digits, 2)) - + "".join(random.sample("~`!@#$%^&*()_+-=[]\\{}|;':<>?,./", 2)) - ) - return "".join(random.sample(s, len(s))) - - -def hashify(some_string, max_len=8): - return hashlib.md5(some_string.encode("utf-8")).hexdigest()[:8].upper() - - -def get_unique_project_name(): - user = re.sub("[^A-Z_]", "", getpass.getuser().upper()) - if "GITHUB_ACTION" in os.environ and "GITHUB_RUN_NUMBER" in os.environ: - # please note that user underline (_) is not supported by - # Jira even if it is documented as supported. - return "GH" + hashify(user + os.environ["GITHUB_RUN_NUMBER"]) - identifier = ( - user + chr(ord("A") + sys.version_info[0]) + chr(ord("A") + sys.version_info[1]) - ) - return "Z" + hashify(identifier) - - -class JiraTestManager(object): - """Used to instantiate and populate the JIRA instance with data used by the unit tests. - - Attributes: - CI_JIRA_ADMIN (str): Admin user account name. - CI_JIRA_USER (str): Limited user account name. - max_retries (int): number of retries to perform for recoverable HTTP errors. - """ - - __shared_state = {} # type: Dict[Any, Any] - - def __init__(self): - self.__dict__ = self.__shared_state - - if not self.__dict__: - self.initialized = 0 - self.max_retries = 5 - - if "CI_JIRA_URL" in os.environ: - self.CI_JIRA_URL = os.environ["CI_JIRA_URL"] - else: - self.CI_JIRA_URL = "https://pycontribs.atlassian.net" - if "CI_JIRA_ADMIN" in os.environ: - self.CI_JIRA_ADMIN = os.environ["CI_JIRA_ADMIN"] - else: - self.CI_JIRA_ADMIN = "ci-admin" - - if "CI_JIRA_ADMIN_PASSWORD" in os.environ: - self.CI_JIRA_ADMIN_PASSWORD = os.environ["CI_JIRA_ADMIN_PASSWORD"] - else: - self.CI_JIRA_ADMIN_PASSWORD = "sd4s3dgec5fhg4tfsds3434" - - if "CI_JIRA_USER" in os.environ: - self.CI_JIRA_USER = os.environ["CI_JIRA_USER"] - else: - self.CI_JIRA_USER = "ci-user" - - if "CI_JIRA_USER_PASSWORD" in os.environ: - self.CI_JIRA_USER_PASSWORD = os.environ["CI_JIRA_USER_PASSWORD"] - else: - self.CI_JIRA_USER_PASSWORD = "sd4s3dgec5fhg4tfsds3434" - - self.CI_JIRA_ISSUE = os.environ.get("CI_JIRA_ISSUE", "Bug") - - if OAUTH: - self.jira_admin = JIRA( - oauth={ - "access_token": "hTxcwsbUQiFuFALf7KZHDaeAJIo3tLUK", - "access_token_secret": "aNCLQFP3ORNU6WY7HQISbqbhf0UudDAf", - "consumer_key": CONSUMER_KEY, - "key_cert": KEY_CERT_DATA, - } - ) - self.jira_sysadmin = JIRA( - oauth={ - "access_token": "4ul1ETSFo7ybbIxAxzyRal39cTrwEGFv", - "access_token_secret": "K83jBZnjnuVRcfjBflrKyThJa0KSjSs2", - "consumer_key": CONSUMER_KEY, - "key_cert": KEY_CERT_DATA, - }, - logging=False, - max_retries=self.max_retries, - ) - self.jira_normal = JIRA( - oauth={ - "access_token": "ZVDgYDyIQqJY8IFlQ446jZaURIz5ECiB", - "access_token_secret": "5WbLBybPDg1lqqyFjyXSCsCtAWTwz1eD", - "consumer_key": CONSUMER_KEY, - "key_cert": KEY_CERT_DATA, - } - ) - else: - if self.CI_JIRA_ADMIN: - self.jira_admin = JIRA( - self.CI_JIRA_URL, - basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), - logging=False, - validate=True, - max_retries=self.max_retries, - ) - self.jira_sysadmin = JIRA( - self.CI_JIRA_URL, - basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), - logging=False, - validate=True, - max_retries=self.max_retries, - ) - self.jira_normal = JIRA( - self.CI_JIRA_URL, - basic_auth=(self.CI_JIRA_USER, self.CI_JIRA_USER_PASSWORD), - validate=True, - logging=False, - max_retries=self.max_retries, - ) - else: - self.jira_admin = JIRA( - self.CI_JIRA_URL, - validate=True, - logging=False, - max_retries=self.max_retries, - ) - self.jira_sysadmin = JIRA( - self.CI_JIRA_URL, logging=False, max_retries=self.max_retries - ) - self.jira_normal = JIRA( - self.CI_JIRA_URL, - validate=True, - logging=False, - max_retries=self.max_retries, - ) - if not self.jira_admin.current_user(): - self.initialized = 1 - sys.exit(3) - - # now we need some data to start with for the tests - - # jira project key is max 10 chars, no letter. - # [0] always "Z" - # [1-6] username running the tests (hope we will not collide) - # [7-8] python version A=0, B=1,.. - # [9] A,B -- we may need more than one project - - """ `jid` is important for avoiding concurrency problems when - executing tests in parallel as we have only one test instance. - - jid length must be less than 9 characters because we may append - another one and the Jira Project key length limit is 10. - """ - - self.jid = get_unique_project_name() - - self.project_a = self.jid + "A" # old XSS - self.project_a_name = "Test user=%s key=%s A" % ( - getpass.getuser(), - self.project_a, - ) - self.project_b = self.jid + "B" # old BULK - self.project_b_name = "Test user=%s key=%s B" % ( - getpass.getuser(), - self.project_b, - ) - self.project_sd = self.jid + "C" - self.project_sd_name = "Test user=%s key=%s C" % ( - getpass.getuser(), - self.project_b, - ) - - # TODO(ssbarnea): find a way to prevent SecurityTokenMissing for On Demand - # https://jira.atlassian.com/browse/JRA-39153 - try: - self.jira_admin.project(self.project_a) - except Exception as e: - logging.warning(e) - else: - try: - self.jira_admin.delete_project(self.project_a) - except Exception as e: # noqa - pass - - try: - self.jira_admin.project(self.project_b) - except Exception as e: - logging.warning(e) - else: - try: - self.jira_admin.delete_project(self.project_b) - except Exception as e: # noqa - pass - - # wait for the project to be deleted - for _ in range(1, 20): - try: - self.jira_admin.project(self.project_b) - except Exception: - break - print("Warning: Project not deleted yet....") - sleep(2) - - for _ in range(6): - try: - if self.jira_admin.create_project( - self.project_a, self.project_a_name - ): - break - except Exception as e: - if "A project with that name already exists" not in str(e): - raise e - self.project_a_id = self.jira_admin.project(self.project_a).id - self.jira_admin.create_project(self.project_b, self.project_b_name) - - try: - self.jira_admin.create_project(self.project_b, self.project_b_name) - except Exception: - # we care only for the project to exist - pass - sleep(1) # keep it here as often Jira will report the - # project as missing even after is created - self.project_b_issue1_obj = self.jira_admin.create_issue( - project=self.project_b, - summary="issue 1 from %s" % self.project_b, - issuetype=self.CI_JIRA_ISSUE, - ) - self.project_b_issue1 = self.project_b_issue1_obj.key - - self.project_b_issue2_obj = self.jira_admin.create_issue( - project=self.project_b, - summary="issue 2 from %s" % self.project_b, - issuetype={"name": self.CI_JIRA_ISSUE}, - ) - self.project_b_issue2 = self.project_b_issue2_obj.key - - self.project_b_issue3_obj = self.jira_admin.create_issue( - project=self.project_b, - summary="issue 3 from %s" % self.project_b, - issuetype={"name": self.CI_JIRA_ISSUE}, - ) - self.project_b_issue3 = self.project_b_issue3_obj.key - - if not hasattr(self, "jira_normal") or not hasattr(self, "jira_admin"): - py.test.exit("FATAL: WTF!?") - - self.user_admin = self.jira_admin.search_users(self.CI_JIRA_ADMIN)[0] - self.user_normal = self.jira_admin.search_users(self.CI_JIRA_USER)[0] - self.initialized = 1 - - -def find_by_key(seq, key): - for seq_item in seq: - if seq_item["key"] == key: - return seq_item - - -def find_by_key_value(seq, key): - for seq_item in seq: - if seq_item.key == key: - return seq_item +from jira import JIRA, Issue, JIRAError +from jira.client import ResultList +from jira.resources import cls_for_resource +from tests.conftest import ( + JiraTestCase, + broken_test, + not_on_custom_jira_instance, + rndpassword, +) -def find_by_id(seq, id): - for seq_item in seq: - if seq_item.id == id: - return seq_item +LOGGER = logging.getLogger(__name__) -def find_by_name(seq, name): - for seq_item in seq: - if seq_item["name"] == name: - return seq_item - - -@flaky -class UniversalResourceTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - self.test_manager = JiraTestManager() - +class UniversalResourceTests(JiraTestCase): def test_universal_find_existing_resource(self): resource = self.jira.find("issue/{0}", self.test_manager.project_b_issue1) issue = self.jira.issue(self.test_manager.project_b_issue1) @@ -424,54 +118,7 @@ def test_hashable_issue_object(self): assert dict_of_resource[resource] == "hey ho" -@flaky -class ResourceTests(unittest.TestCase): - def setUp(self): - pass - - def test_cls_for_resource(self): - self.assertEqual( - cls_for_resource( - "https://jira.atlassian.com/rest/\ - api/latest/issue/JRA-1330" - ), - Issue, - ) - self.assertEqual( - cls_for_resource( - "http://localhost:2990/jira/rest/\ - api/latest/project/BULK" - ), - Project, - ) - self.assertEqual( - cls_for_resource( - "http://imaginary-jira.com/rest/\ - api/latest/project/IMG/role/10002" - ), - Role, - ) - self.assertEqual( - cls_for_resource( - "http://customized-jira.com/rest/\ - plugin-resource/4.5/json/getMyObject" - ), - UnknownResource, - ) - self.assertEqual( - cls_for_resource( - "http://customized-jira.com/rest/\ - group?groupname=bla" - ), - Group, - ) - - -@flaky -class ApplicationPropertiesTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - +class ApplicationPropertiesTests(JiraTestCase): def test_application_properties(self): props = self.jira.application_properties() for p in props: @@ -505,1003 +152,16 @@ def test_setting_bad_property_raises(self): self.assertRaises(JIRAError, self.jira.set_application_property, prop, "666") -@flaky -class AttachmentTests(unittest.TestCase): - def setUp(self): - self.test_manager = JiraTestManager() - self.jira = JiraTestManager().jira_admin - self.project_b = self.test_manager.project_b - self.issue_1 = self.test_manager.project_b_issue1 - self.attachment = None - - def test_0_attachment_meta(self): - meta = self.jira.attachment_meta() - self.assertTrue(meta["enabled"]) - # we have no control over server side upload limit - self.assertIn("uploadLimit", meta) - - def test_1_add_remove_attachment(self): - issue = self.jira.issue(self.issue_1) - with open(TEST_ATTACH_PATH, "rb") as f: - attachment = self.jira.add_attachment(issue, f, "new test attachment") - new_attachment = self.jira.attachment(attachment.id) - msg = "attachment %s of issue %s" % (new_attachment.__dict__, issue) - self.assertEqual(new_attachment.filename, "new test attachment", msg=msg) - self.assertEqual( - new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg - ) - # JIRA returns a HTTP 204 upon successful deletion - self.assertEqual(attachment.delete().status_code, 204) - - -@flaky -class ComponentTests(unittest.TestCase): - def setUp(self): - self.test_manager = JiraTestManager() - self.jira = JiraTestManager().jira_admin - self.project_b = self.test_manager.project_b - self.issue_1 = self.test_manager.project_b_issue1 - self.issue_2 = self.test_manager.project_b_issue2 - - def test_2_create_component(self): - proj = self.jira.project(self.project_b) - name = "project-%s-component-%s" % (proj, rndstr()) - component = self.jira.create_component( - name, - proj, - description="test!!", - assigneeType="COMPONENT_LEAD", - isAssigneeTypeValid=False, - ) - self.assertEqual(component.name, name) - self.assertEqual(component.description, "test!!") - self.assertEqual(component.assigneeType, "COMPONENT_LEAD") - self.assertFalse(component.isAssigneeTypeValid) - component.delete() - - # Components field can't be modified from issue.update - # def test_component_count_related_issues(self): - # component = self.jira.create_component('PROJECT_B_TEST',self.project_b, description='test!!', - # assigneeType='COMPONENT_LEAD', isAssigneeTypeValid=False) - # issue1 = self.jira.issue(self.issue_1) - # issue2 = self.jira.issue(self.issue_2) - # (issue1.update ({'components': ['PROJECT_B_TEST']})) - # (issue2.update (components = ['PROJECT_B_TEST'])) - # issue_count = self.jira.component_count_related_issues(component.id) - # self.assertEqual(issue_count, 2) - # component.delete() - - def test_3_update(self): - try: - components = self.jira.project_components(self.project_b) - for component in components: - if component.name == "To be updated": - component.delete() - break - except Exception: - # We ignore errors as this code intends only to prepare for - # component creation - raise - - name = "component-" + rndstr() - - component = self.jira.create_component( - name, - self.project_b, - description="stand by!", - leadUserName=self.jira.current_user(), - ) - name = "renamed-" + name - component.update( - name=name, description="It is done.", leadUserName=self.jira.current_user() - ) - self.assertEqual(component.name, name) - self.assertEqual(component.description, "It is done.") - self.assertEqual(component.lead.name, self.jira.current_user()) - component.delete() - - def test_4_delete(self): - component = self.jira.create_component( - "To be deleted", self.project_b, description="not long for this world" - ) - myid = component.id - component.delete() - self.assertRaises(JIRAError, self.jira.component, myid) - - def test_delete_component_by_id(self): - component = self.jira.create_component( - "To be deleted", self.project_b, description="not long for this world" - ) - myid = component.id - self.jira.delete_component(myid) - self.assertRaises(JIRAError, self.jira.component, myid) - - -@flaky -class CustomFieldOptionTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - - @not_on_custom_jira_instance - def test_custom_field_option(self): - option = self.jira.custom_field_option("10001") - self.assertEqual(option.value, "To Do") - - @not_on_custom_jira_instance -@flaky -class DashboardTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - - def test_dashboards(self): - dashboards = self.jira.dashboards() - self.assertEqual(len(dashboards), 3) - - def test_dashboards_filter(self): - dashboards = self.jira.dashboards(filter="my") - self.assertEqual(len(dashboards), 2) - self.assertEqual(dashboards[0].id, "10101") - - def test_dashboards_startat(self): - dashboards = self.jira.dashboards(startAt=1, maxResults=1) - self.assertEqual(len(dashboards), 1) - - def test_dashboards_maxresults(self): - dashboards = self.jira.dashboards(maxResults=1) - self.assertEqual(len(dashboards), 1) - - def test_dashboard(self): - dashboard = self.jira.dashboard("10101") - self.assertEqual(dashboard.id, "10101") - self.assertEqual(dashboard.name, "Another test dashboard") - - -@not_on_custom_jira_instance -@flaky -class FieldsTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - +class FieldsTests(JiraTestCase): def test_fields(self): fields = self.jira.fields() self.assertGreater(len(fields), 10) -@flaky -class FilterTests(unittest.TestCase): - def setUp(self): - self.test_manager = JiraTestManager() - self.jira = JiraTestManager().jira_admin - self.project_b = self.test_manager.project_b - self.issue_1 = self.test_manager.project_b_issue1 - self.issue_2 = self.test_manager.project_b_issue2 - - def test_filter(self): - jql = "project = %s and component is not empty" % self.project_b - name = "same filter " + rndstr() - myfilter = self.jira.create_filter( - name=name, description="just some new test filter", jql=jql, favourite=False - ) - self.assertEqual(myfilter.name, name) - self.assertEqual(myfilter.owner.name, self.test_manager.user_admin.name) - myfilter.delete() - - def test_favourite_filters(self): - # filters = self.jira.favourite_filters() - jql = "project = %s and component is not empty" % self.project_b - name = "filter-to-fav-" + rndstr() - myfilter = self.jira.create_filter( - name=name, description="just some new test filter", jql=jql, favourite=True - ) - new_filters = self.jira.favourite_filters() - - assert name in [f.name for f in new_filters] - myfilter.delete() - - -@not_on_custom_jira_instance -@flaky -class GroupsTest(unittest.TestCase): - def setUp(self): - self.test_manager = JiraTestManager() - self.jira = self.test_manager.jira_admin - - def test_group(self): - group = self.jira.group("jira-users") - self.assertEqual(group.name, "jira-users") - - def test_groups(self): - groups = self.jira.groups() - self.assertGreater(len(groups), 0) - - def test_groups_for_users(self): - groups = self.jira.groups("jira-users") - self.assertGreater(len(groups), 0) - - -@flaky -class IssueTests(unittest.TestCase): - def setUp(self): - self.test_manager = JiraTestManager() - self.jira = JiraTestManager().jira_admin - self.jira_normal = JiraTestManager().jira_normal - self.user_admin = self.jira.search_users(self.test_manager.CI_JIRA_ADMIN)[0] - self.user_normal = self.test_manager.user_normal - self.project_b = self.test_manager.project_b - self.project_a = self.test_manager.project_a - self.issue_1 = self.test_manager.project_b_issue1 - self.issue_2 = self.test_manager.project_b_issue2 - self.issue_3 = self.test_manager.project_b_issue3 - - def test_issue(self): - issue = self.jira.issue(self.issue_1) - self.assertEqual(issue.key, self.issue_1) - self.assertEqual(issue.fields.summary, "issue 1 from %s" % self.project_b) - - @unittest.skip("disabled as it seems to be ignored by jira, returning all") - def test_issue_field_limiting(self): - issue = self.jira.issue(self.issue_2, fields="summary,comment") - self.assertEqual(issue.fields.summary, "issue 2 from %s" % self.project_b) - comment1 = self.jira.add_comment(issue, "First comment") - comment2 = self.jira.add_comment(issue, "Second comment") - comment3 = self.jira.add_comment(issue, "Third comment") - self.jira.issue(self.issue_2, fields="summary,comment") - logging.warning(issue.raw["fields"]) - self.assertFalse(hasattr(issue.fields, "reporter")) - self.assertFalse(hasattr(issue.fields, "progress")) - comment1.delete() - comment2.delete() - comment3.delete() - - def test_issue_equal(self): - issue1 = self.jira.issue(self.issue_1) - issue2 = self.jira.issue(self.issue_2) - issues = self.jira.search_issues("key=%s" % self.issue_1) - self.assertTrue(issue1 is not None) - self.assertTrue(issue1 == issues[0]) - self.assertFalse(issue2 == issues[0]) - - def test_issue_expand(self): - issue = self.jira.issue(self.issue_1, expand="editmeta,schema") - self.assertTrue(hasattr(issue, "editmeta")) - self.assertTrue(hasattr(issue, "schema")) - # testing for changelog is not reliable because it may exist or not based on test order - # self.assertFalse(hasattr(issue, 'changelog')) - - @not_on_custom_jira_instance - def test_create_issue_with_fieldargs(self): - issue = self.jira.create_issue( - project=self.project_b, - summary="Test issue created", - description="foo description", - issuetype={"name": "Bug"}, - ) # customfield_10022='XSS' - self.assertEqual(issue.fields.summary, "Test issue created") - self.assertEqual(issue.fields.description, "foo description") - self.assertEqual(issue.fields.issuetype.name, "Bug") - self.assertEqual(issue.fields.project.key, self.project_b) - # self.assertEqual(issue.fields.customfield_10022, 'XSS') - issue.delete() - - @not_on_custom_jira_instance - def test_create_issue_with_fielddict(self): - fields = { - "project": {"key": self.project_b}, - "summary": "Issue created from field dict", - "description": "Some new issue for test", - "issuetype": {"name": "Bug"}, - # 'customfield_10022': 'XSS', - "priority": {"name": "Major"}, - } - issue = self.jira.create_issue(fields=fields) - self.assertEqual(issue.fields.summary, "Issue created from field dict") - self.assertEqual(issue.fields.description, "Some new issue for test") - self.assertEqual(issue.fields.issuetype.name, "Bug") - self.assertEqual(issue.fields.project.key, self.project_b) - # self.assertEqual(issue.fields.customfield_10022, 'XSS') - self.assertEqual(issue.fields.priority.name, "Major") - issue.delete() - - @not_on_custom_jira_instance - def test_create_issue_without_prefetch(self): - issue = self.jira.create_issue( - prefetch=False, - project=self.project_b, - summary="Test issue created", - description="some details", - issuetype={"name": "Bug"}, - ) # customfield_10022='XSS' - - assert hasattr(issue, "self") - assert hasattr(issue, "raw") - assert "fields" not in issue.raw - issue.delete() - - @not_on_custom_jira_instance - def test_create_issues(self): - field_list = [ - { - "project": {"key": self.project_b}, - "summary": "Issue created via bulk create #1", - "description": "Some new issue for test", - "issuetype": {"name": "Bug"}, - # 'customfield_10022': 'XSS', - "priority": {"name": "Major"}, - }, - { - "project": {"key": self.project_a}, - "issuetype": {"name": "Bug"}, - "summary": "Issue created via bulk create #2", - "description": "Another new issue for bulk test", - "priority": {"name": "Major"}, - }, - ] - issues = self.jira.create_issues(field_list=field_list) - self.assertEqual(len(issues), 2) - self.assertIsNotNone(issues[0]["issue"], "the first issue has not been created") - self.assertEqual( - issues[0]["issue"].fields.summary, "Issue created via bulk create #1" - ) - self.assertEqual( - issues[0]["issue"].fields.description, "Some new issue for test" - ) - self.assertEqual(issues[0]["issue"].fields.issuetype.name, "Bug") - self.assertEqual(issues[0]["issue"].fields.project.key, self.project_b) - self.assertEqual(issues[0]["issue"].fields.priority.name, "Major") - self.assertIsNotNone( - issues[1]["issue"], "the second issue has not been created" - ) - self.assertEqual( - issues[1]["issue"].fields.summary, "Issue created via bulk create #2" - ) - self.assertEqual( - issues[1]["issue"].fields.description, "Another new issue for bulk test" - ) - self.assertEqual(issues[1]["issue"].fields.issuetype.name, "Bug") - self.assertEqual(issues[1]["issue"].fields.project.key, self.project_a) - self.assertEqual(issues[1]["issue"].fields.priority.name, "Major") - for issue in issues: - issue["issue"].delete() - - @not_on_custom_jira_instance - def test_create_issues_one_failure(self): - field_list = [ - { - "project": {"key": self.project_b}, - "summary": "Issue created via bulk create #1", - "description": "Some new issue for test", - "issuetype": {"name": "Bug"}, - # 'customfield_10022': 'XSS', - "priority": {"name": "Major"}, - }, - { - "project": {"key": self.project_a}, - "issuetype": {"name": "InvalidIssueType"}, - "summary": "This issue will not succeed", - "description": "Should not be seen.", - "priority": {"name": "Blah"}, - }, - { - "project": {"key": self.project_a}, - "issuetype": {"name": "Bug"}, - "summary": "However, this one will.", - "description": "Should be seen.", - "priority": {"name": "Major"}, - }, - ] - issues = self.jira.create_issues(field_list=field_list) - self.assertEqual( - issues[0]["issue"].fields.summary, "Issue created via bulk create #1" - ) - self.assertEqual( - issues[0]["issue"].fields.description, "Some new issue for test" - ) - self.assertEqual(issues[0]["issue"].fields.issuetype.name, "Bug") - self.assertEqual(issues[0]["issue"].fields.project.key, self.project_b) - self.assertEqual(issues[0]["issue"].fields.priority.name, "Major") - self.assertEqual(issues[0]["error"], None) - self.assertEqual(issues[1]["issue"], None) - self.assertEqual(issues[1]["error"], {"issuetype": "issue type is required"}) - self.assertEqual(issues[1]["input_fields"], field_list[1]) - self.assertEqual(issues[2]["issue"].fields.summary, "However, this one will.") - self.assertEqual(issues[2]["issue"].fields.description, "Should be seen.") - self.assertEqual(issues[2]["issue"].fields.issuetype.name, "Bug") - self.assertEqual(issues[2]["issue"].fields.project.key, self.project_a) - self.assertEqual(issues[2]["issue"].fields.priority.name, "Major") - self.assertEqual(issues[2]["error"], None) - self.assertEqual(len(issues), 3) - for issue in issues: - if issue["issue"] is not None: - issue["issue"].delete() - - @not_on_custom_jira_instance - def test_create_issues_without_prefetch(self): - field_list = [ - dict( - project=self.project_b, - summary="Test issue created", - description="some details", - issuetype={"name": "Bug"}, - ), - dict( - project=self.project_a, - summary="Test issue #2", - description="foo description", - issuetype={"name": "Bug"}, - ), - ] - issues = self.jira.create_issues(field_list, prefetch=False) - - assert hasattr(issues[0]["issue"], "self") - assert hasattr(issues[0]["issue"], "raw") - assert hasattr(issues[1]["issue"], "self") - assert hasattr(issues[1]["issue"], "raw") - assert "fields" not in issues[0]["issue"].raw - assert "fields" not in issues[1]["issue"].raw - for issue in issues: - issue["issue"].delete() - - @not_on_custom_jira_instance - def test_update_with_fieldargs(self): - issue = self.jira.create_issue( - project=self.project_b, - summary="Test issue for updating", - description="Will be updated shortly", - issuetype={"name": "Bug"}, - ) - # customfield_10022='XSS') - issue.update( - summary="Updated summary", - description="Now updated", - issuetype={"name": "Story"}, - ) - self.assertEqual(issue.fields.summary, "Updated summary") - self.assertEqual(issue.fields.description, "Now updated") - self.assertEqual(issue.fields.issuetype.name, "Story") - # self.assertEqual(issue.fields.customfield_10022, 'XSS') - self.assertEqual(issue.fields.project.key, self.project_b) - issue.delete() - - @not_on_custom_jira_instance - def test_update_with_fielddict(self): - issue = self.jira.create_issue( - project=self.project_b, - summary="Test issue for updating", - description="Will be updated shortly", - issuetype={"name": "Bug"}, - ) - fields = { - "summary": "Issue is updated", - "description": "it sure is", - "issuetype": {"name": "Story"}, - # 'customfield_10022': 'DOC', - "priority": {"name": "Major"}, - } - issue.update(fields=fields) - self.assertEqual(issue.fields.summary, "Issue is updated") - self.assertEqual(issue.fields.description, "it sure is") - self.assertEqual(issue.fields.issuetype.name, "Story") - # self.assertEqual(issue.fields.customfield_10022, 'DOC') - self.assertEqual(issue.fields.priority.name, "Major") - issue.delete() - - def test_update_with_label(self): - issue = self.jira.create_issue( - project=self.project_b, - summary="Test issue for updating labels", - description="Label testing", - issuetype=self.test_manager.CI_JIRA_ISSUE, - ) - - labelarray = ["testLabel"] - fields = {"labels": labelarray} - - issue.update(fields=fields) - self.assertEqual(issue.fields.labels, ["testLabel"]) - - def test_update_with_bad_label(self): - issue = self.jira.create_issue( - project=self.project_b, - summary="Test issue for updating labels", - description="Label testing", - issuetype=self.test_manager.CI_JIRA_ISSUE, - ) - - issue.fields.labels.append("this should not work") - - fields = {"labels": issue.fields.labels} - - self.assertRaises(JIRAError, issue.update, fields=fields) - - @not_on_custom_jira_instance - def test_update_with_notify_false(self): - issue = self.jira.create_issue( - project=self.project_b, - summary="Test issue for updating", - description="Will be updated shortly", - issuetype={"name": "Bug"}, - ) - issue.update(notify=False, description="Now updated, but silently") - self.assertEqual(issue.fields.description, "Now updated, but silently") - issue.delete() - - def test_delete(self): - issue = self.jira.create_issue( - project=self.project_b, - summary="Test issue created", - description="Not long for this world", - issuetype=self.test_manager.CI_JIRA_ISSUE, - ) - key = issue.key - issue.delete() - self.assertRaises(JIRAError, self.jira.issue, key) - - @not_on_custom_jira_instance - def test_createmeta(self): - meta = self.jira.createmeta() - proj = find_by_key(meta["projects"], self.project_b) - # we assume that this project should allow at least one issue type - self.assertGreaterEqual(len(proj["issuetypes"]), 1) - - @not_on_custom_jira_instance - def test_createmeta_filter_by_projectkey_and_name(self): - meta = self.jira.createmeta(projectKeys=self.project_b, issuetypeNames="Bug") - self.assertEqual(len(meta["projects"]), 1) - self.assertEqual(len(meta["projects"][0]["issuetypes"]), 1) - - @not_on_custom_jira_instance - def test_createmeta_filter_by_projectkeys_and_name(self): - meta = self.jira.createmeta( - projectKeys=(self.project_a, self.project_b), issuetypeNames="Story" - ) - self.assertEqual(len(meta["projects"]), 2) - for project in meta["projects"]: - self.assertEqual(len(project["issuetypes"]), 1) - - @not_on_custom_jira_instance - def test_createmeta_filter_by_id(self): - projects = self.jira.projects() - proja = find_by_key_value(projects, self.project_a) - projb = find_by_key_value(projects, self.project_b) - issue_type_ids = dict() - full_meta = self.jira.createmeta(projectIds=(proja.id, projb.id)) - for project in full_meta["projects"]: - for issue_t in project["issuetypes"]: - issue_t_id = issue_t["id"] - val = issue_type_ids.get(issue_t_id) - if val is None: - issue_type_ids[issue_t_id] = [] - issue_type_ids[issue_t_id].append([project["id"]]) - common_issue_ids = [] - for key, val in issue_type_ids.items(): - if len(val) == 2: - common_issue_ids.append(key) - self.assertNotEqual(len(common_issue_ids), 0) - for_lookup_common_issue_ids = common_issue_ids - if len(common_issue_ids) > 2: - for_lookup_common_issue_ids = common_issue_ids[:-1] - meta = self.jira.createmeta( - projectIds=(proja.id, projb.id), issuetypeIds=for_lookup_common_issue_ids - ) - self.assertEqual(len(meta["projects"]), 2) - for project in meta["projects"]: - self.assertEqual( - len(project["issuetypes"]), len(for_lookup_common_issue_ids) - ) - - def test_createmeta_expand(self): - # limit to SCR project so the call returns promptly - meta = self.jira.createmeta( - projectKeys=self.project_b, expand="projects.issuetypes.fields" - ) - self.assertTrue("fields" in meta["projects"][0]["issuetypes"][0]) - - def test_assign_issue(self): - self.assertTrue(self.jira.assign_issue(self.issue_1, self.user_normal.name)) - self.assertEqual( - self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name - ) - - def test_assign_issue_with_issue_obj(self): - issue = self.jira.issue(self.issue_1) - x = self.jira.assign_issue(issue, self.user_normal.name) - self.assertTrue(x) - self.assertEqual( - self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name - ) - - def test_assign_to_bad_issue_raises(self): - self.assertRaises(JIRAError, self.jira.assign_issue, "NOPE-1", "notauser") - - def test_comments(self): - for issue in [self.issue_1, self.jira.issue(self.issue_2)]: - self.jira.issue(issue) - comment1 = self.jira.add_comment(issue, "First comment") - comment2 = self.jira.add_comment(issue, "Second comment") - comments = self.jira.comments(issue) - assert comments[0].body == "First comment" - assert comments[1].body == "Second comment" - comment1.delete() - comment2.delete() - comments = self.jira.comments(issue) - assert len(comments) == 0 - - def test_expanded_comments(self): - comment1 = self.jira.add_comment(self.issue_1, "First comment") - comment2 = self.jira.add_comment(self.issue_1, "Second comment") - comments = self.jira.comments(self.issue_1, expand="renderedBody") - self.assertTrue(hasattr(comments[0], "renderedBody")) - ret_comment1 = self.jira.comment( - self.issue_1, comment1.id, expand="renderedBody" - ) - ret_comment2 = self.jira.comment(self.issue_1, comment2.id) - comment1.delete() - comment2.delete() - self.assertTrue(hasattr(ret_comment1, "renderedBody")) - self.assertFalse(hasattr(ret_comment2, "renderedBody")) - comments = self.jira.comments(self.issue_1) - assert len(comments) == 0 - - def test_add_comment(self): - comment = self.jira.add_comment( - self.issue_3, - "a test comment!", - visibility={"type": "role", "value": "Administrators"}, - ) - self.assertEqual(comment.body, "a test comment!") - self.assertEqual(comment.visibility.type, "role") - self.assertEqual(comment.visibility.value, "Administrators") - comment.delete() - - def test_add_comment_with_issue_obj(self): - issue = self.jira.issue(self.issue_3) - comment = self.jira.add_comment( - issue, - "a new test comment!", - visibility={"type": "role", "value": "Administrators"}, - ) - self.assertEqual(comment.body, "a new test comment!") - self.assertEqual(comment.visibility.type, "role") - self.assertEqual(comment.visibility.value, "Administrators") - comment.delete() - - def test_update_comment(self): - comment = self.jira.add_comment(self.issue_3, "updating soon!") - comment.update(body="updated!") - self.assertEqual(comment.body, "updated!") - # self.assertEqual(comment.visibility.type, 'role') - # self.assertEqual(comment.visibility.value, 'Administrators') - comment.delete() - - def test_editmeta(self): - expected_fields = { - "assignee", - "attachment", - "comment", - "components", - "description", - "fixVersions", - "issuelinks", - "labels", - "summary", - } - for i in (self.issue_1, self.issue_2): - meta = self.jira.editmeta(i) - meta_field_set = set(meta["fields"].keys()) - self.assertEqual( - meta_field_set.intersection(expected_fields), expected_fields - ) - - # Nothing from remote link works - # def test_remote_links(self): - # self.jira.add_remote_link ('ZTRAVISDEB-3', globalId='python-test:story.of.horse.riding', - # links = self.jira.remote_links('QA-44') - # self.assertEqual(len(links), 1) - # links = self.jira.remote_links('BULK-1') - # self.assertEqual(len(links), 0) - # - # @unittest.skip("temporary disabled") - # def test_remote_links_with_issue_obj(self): - # issue = self.jira.issue('QA-44') - # links = self.jira.remote_links(issue) - # self.assertEqual(len(links), 1) - # issue = self.jira.issue('BULK-1') - # links = self.jira.remote_links(issue) - # self.assertEqual(len(links), 0) - # - # @unittest.skip("temporary disabled") - # def test_remote_link(self): - # link = self.jira.remote_link('QA-44', '10000') - # self.assertEqual(link.id, 10000) - # self.assertTrue(hasattr(link, 'globalId')) - # self.assertTrue(hasattr(link, 'relationship')) - # - # @unittest.skip("temporary disabled") - # def test_remote_link_with_issue_obj(self): - # issue = self.jira.issue('QA-44') - # link = self.jira.remote_link(issue, '10000') - # self.assertEqual(link.id, 10000) - # self.assertTrue(hasattr(link, 'globalId')) - # self.assertTrue(hasattr(link, 'relationship')) - # - # @unittest.skip("temporary disabled") - # def test_add_remote_link(self): - # link = self.jira.add_remote_link('BULK-3', globalId='python-test:story.of.horse.riding', - # object={'url': 'http://google.com', 'title': 'googlicious!'}, - # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') - # creation response doesn't include full remote link info, so we fetch it again using the new internal ID - # link = self.jira.remote_link('BULK-3', link.id) - # self.assertEqual(link.application.name, 'far too silly') - # self.assertEqual(link.application.type, 'sketch') - # self.assertEqual(link.object.url, 'http://google.com') - # self.assertEqual(link.object.title, 'googlicious!') - # self.assertEqual(link.relationship, 'mousebending') - # self.assertEqual(link.globalId, 'python-test:story.of.horse.riding') - # - # @unittest.skip("temporary disabled") - # def test_add_remote_link_with_issue_obj(self): - # issue = self.jira.issue('BULK-3') - # link = self.jira.add_remote_link(issue, globalId='python-test:story.of.horse.riding', - # object={'url': 'http://google.com', 'title': 'googlicious!'}, - # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') - # creation response doesn't include full remote link info, so we fetch it again using the new internal ID - # link = self.jira.remote_link(issue, link.id) - # self.assertEqual(link.application.name, 'far too silly') - # self.assertEqual(link.application.type, 'sketch') - # self.assertEqual(link.object.url, 'http://google.com') - # self.assertEqual(link.object.title, 'googlicious!') - # self.assertEqual(link.relationship, 'mousebending') - # self.assertEqual(link.globalId, 'python-test:story.of.horse.riding') - # - # @unittest.skip("temporary disabled") - # def test_update_remote_link(self): - # link = self.jira.add_remote_link('BULK-3', globalId='python-test:story.of.horse.riding', - # object={'url': 'http://google.com', 'title': 'googlicious!'}, - # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') - # creation response doesn't include full remote link info, so we fetch it again using the new internal ID - # link = self.jira.remote_link('BULK-3', link.id) - # link.update(object={'url': 'http://yahoo.com', 'title': 'yahoo stuff'}, globalId='python-test:updated.id', - # relationship='cheesing') - # self.assertEqual(link.globalId, 'python-test:updated.id') - # self.assertEqual(link.relationship, 'cheesing') - # self.assertEqual(link.object.url, 'http://yahoo.com') - # self.assertEqual(link.object.title, 'yahoo stuff') - # link.delete() - # - # @unittest.skip("temporary disabled") - # def test_delete_remove_link(self): - # link = self.jira.add_remote_link('BULK-3', globalId='python-test:story.of.horse.riding', - # object={'url': 'http://google.com', 'title': 'googlicious!'}, - # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') - # _id = link.id - # link.delete() - # self.assertRaises(JIRAError, self.jira.remote_link, 'BULK-3', _id) - - def test_transitioning(self): - # we check with both issue-as-string or issue-as-object - transitions = [] - for issue in [self.issue_2, self.jira.issue(self.issue_2)]: - transitions = self.jira.transitions(issue) - self.assertTrue(transitions) - self.assertTrue("id" in transitions[0]) - self.assertTrue("name" in transitions[0]) - - self.assertTrue(transitions, msg="Expecting at least one transition") - # we test getting a single transition - transition = self.jira.transitions(self.issue_2, transitions[0]["id"])[0] - self.assertDictEqual(transition, transitions[0]) - - # we test the expand of fields - transition = self.jira.transitions( - self.issue_2, transitions[0]["id"], expand="transitions.fields" - )[0] - self.assertTrue("fields" in transition) - - # Testing of transition with field assignment is disabled now because default workflows do not have it. - - # self.jira.transition_issue(issue, transitions[0]['id'], assignee={'name': self.test_manager.CI_JIRA_ADMIN}) - # issue = self.jira.issue(issue.key) - # self.assertEqual(issue.fields.assignee.name, self.test_manager.CI_JIRA_ADMIN) - # - # fields = { - # 'assignee': { - # 'name': self.test_manager.CI_JIRA_USER - # } - # } - # transitions = self.jira.transitions(issue.key) - # self.assertTrue(transitions) # any issue should have at least one transition available to it - # transition_id = transitions[0]['id'] - # - # self.jira.transition_issue(issue.key, transition_id, fields=fields) - # issue = self.jira.issue(issue.key) - # self.assertEqual(issue.fields.assignee.name, self.test_manager.CI_JIRA_USER) - # self.assertEqual(issue.fields.status.id, transition_id) - - def test_votes(self): - self.jira_normal.remove_vote(self.issue_1) - # not checking the result on this - votes = self.jira.votes(self.issue_1) - self.assertEqual(votes.votes, 0) - - self.jira_normal.add_vote(self.issue_1) - new_votes = self.jira.votes(self.issue_1) - assert votes.votes + 1 == new_votes.votes - - self.jira_normal.remove_vote(self.issue_1) - new_votes = self.jira.votes(self.issue_1) - assert votes.votes == new_votes.votes - - def test_votes_with_issue_obj(self): - issue = self.jira_normal.issue(self.issue_1) - self.jira_normal.remove_vote(issue) - # not checking the result on this - votes = self.jira.votes(issue) - self.assertEqual(votes.votes, 0) - - self.jira_normal.add_vote(issue) - new_votes = self.jira.votes(issue) - assert votes.votes + 1 == new_votes.votes - - self.jira_normal.remove_vote(issue) - new_votes = self.jira.votes(issue) - assert votes.votes == new_votes.votes - - def test_add_remove_watcher(self): - - # removing it in case it exists, so we know its state - self.jira.remove_watcher(self.issue_1, self.test_manager.user_normal.name) - init_watchers = self.jira.watchers(self.issue_1).watchCount - - # adding a new watcher - self.jira.add_watcher(self.issue_1, self.test_manager.user_normal.name) - self.assertEqual(self.jira.watchers(self.issue_1).watchCount, init_watchers + 1) - - # now we verify that remove does indeed remove watchers - self.jira.remove_watcher(self.issue_1, self.test_manager.user_normal.name) - new_watchers = self.jira.watchers(self.issue_1).watchCount - self.assertEqual(init_watchers, new_watchers) - - @not_on_custom_jira_instance - def test_agile(self): - uniq = rndstr() - board_name = "board-" + uniq - sprint_name = "sprint-" + uniq - - b = self.jira.create_board(board_name, self.project_a) - assert isinstance(b.id, int) - - s = self.jira.create_sprint(sprint_name, b.id) - assert isinstance(s.id, int) - assert s.name == sprint_name - assert s.state == "FUTURE" - - self.jira.add_issues_to_sprint(s.id, [self.issue_1]) - - sprint_field_name = "Sprint" - sprint_field_id = [ - f["schema"]["customId"] - for f in self.jira.fields() - if f["name"] == sprint_field_name - ][0] - sprint_customfield = "customfield_" + str(sprint_field_id) - - updated_issue_1 = self.jira.issue(self.issue_1) - serialised_sprint = getattr(updated_issue_1.fields, sprint_customfield)[0] - - # Too hard to serialise the sprint object. Performing simple regex match instead. - assert re.search(r"\[id=" + str(s.id) + ",", serialised_sprint) - - # self.jira.add_issues_to_sprint(s.id, self.issue_2) - - # self.jira.rank(self.issue_2, self.issue_1) - - sleep(2) # avoid https://travis-ci.org/pycontribs/jira/jobs/176561534#L516 - s.delete() - - sleep(2) - b.delete() - # self.jira.delete_board(b.id) - - def test_worklogs(self): - worklog = self.jira.add_worklog(self.issue_1, "2h") - worklogs = self.jira.worklogs(self.issue_1) - self.assertEqual(len(worklogs), 1) - worklog.delete() - - def test_worklogs_with_issue_obj(self): - issue = self.jira.issue(self.issue_1) - worklog = self.jira.add_worklog(issue, "2h") - worklogs = self.jira.worklogs(issue) - self.assertEqual(len(worklogs), 1) - worklog.delete() - - def test_worklog(self): - worklog = self.jira.add_worklog(self.issue_1, "1d 2h") - new_worklog = self.jira.worklog(self.issue_1, str(worklog)) - self.assertEqual(new_worklog.author.name, self.test_manager.user_admin.name) - self.assertEqual(new_worklog.timeSpent, "1d 2h") - worklog.delete() - - def test_worklog_with_issue_obj(self): - issue = self.jira.issue(self.issue_1) - worklog = self.jira.add_worklog(issue, "1d 2h") - new_worklog = self.jira.worklog(issue, str(worklog)) - self.assertEqual(new_worklog.author.name, self.test_manager.user_admin.name) - self.assertEqual(new_worklog.timeSpent, "1d 2h") - worklog.delete() - - def test_add_worklog(self): - worklog_count = len(self.jira.worklogs(self.issue_2)) - worklog = self.jira.add_worklog(self.issue_2, "2h") - self.assertIsNotNone(worklog) - self.assertEqual(len(self.jira.worklogs(self.issue_2)), worklog_count + 1) - worklog.delete() - - def test_add_worklog_with_issue_obj(self): - issue = self.jira.issue(self.issue_2) - worklog_count = len(self.jira.worklogs(issue)) - worklog = self.jira.add_worklog(issue, "2h") - self.assertIsNotNone(worklog) - self.assertEqual(len(self.jira.worklogs(issue)), worklog_count + 1) - worklog.delete() - - def test_update_and_delete_worklog(self): - worklog = self.jira.add_worklog(self.issue_3, "3h") - issue = self.jira.issue(self.issue_3, fields="worklog,timetracking") - worklog.update(comment="Updated!", timeSpent="2h") - self.assertEqual(worklog.comment, "Updated!") - # rem_estimate = issue.fields.timetracking.remainingEstimate - self.assertEqual(worklog.timeSpent, "2h") - issue = self.jira.issue(self.issue_3, fields="worklog,timetracking") - self.assertEqual(issue.fields.timetracking.remainingEstimate, "1h") - worklog.delete() - issue = self.jira.issue(self.issue_3, fields="worklog,timetracking") - self.assertEqual(issue.fields.timetracking.remainingEstimate, "3h") - - -@flaky -class IssueLinkTests(unittest.TestCase): +class MyPermissionsTests(JiraTestCase): def setUp(self): - self.manager = JiraTestManager() - self.link_types = self.manager.jira_admin.issue_link_types() - - def test_issue_link(self): - self.link = self.manager.jira_admin.issue_link_type(self.link_types[0].id) - link = self.link # Duplicate outward - self.assertEqual(link.id, self.link_types[0].id) - - def test_create_issue_link(self): - self.manager.jira_admin.create_issue_link( - self.link_types[0].outward, - JiraTestManager().project_b_issue1, - JiraTestManager().project_b_issue2, - ) - - def test_create_issue_link_with_issue_obj(self): - inwardissue = self.manager.jira_admin.issue(JiraTestManager().project_b_issue1) - self.assertIsNotNone(inwardissue) - outwardissue = self.manager.jira_admin.issue(JiraTestManager().project_b_issue2) - self.assertIsNotNone(outwardissue) - self.manager.jira_admin.create_issue_link( - self.link_types[0].outward, inwardissue, outwardissue - ) - - # @unittest.skip("Creating an issue link doesn't return its ID, so can't easily test delete") - # def test_delete_issue_link(self): - # pass - - def test_issue_link_type(self): - link_type = self.manager.jira_admin.issue_link_type(self.link_types[0].id) - self.assertEqual(link_type.id, self.link_types[0].id) - self.assertEqual(link_type.name, self.link_types[0].name) - - -@flaky -class MyPermissionsTests(unittest.TestCase): - def setUp(self): - self.test_manager = JiraTestManager() - self.jira = JiraTestManager().jira_normal + JiraTestCase.setUp(self) self.issue_1 = self.test_manager.project_b_issue1 def test_my_permissions(self): @@ -1514,7 +174,7 @@ def test_my_permissions_by_project(self): perms = self.jira.my_permissions(projectId=self.test_manager.project_a_id) self.assertGreaterEqual(len(perms["permissions"]), 10) - @unittest.skip("broken") + @broken_test(reason="broken") def test_my_permissions_by_issue(self): perms = self.jira.my_permissions(issueKey="ZTRAVISDEB-7") self.assertGreaterEqual(len(perms["permissions"]), 10) @@ -1522,258 +182,14 @@ def test_my_permissions_by_issue(self): self.assertGreaterEqual(len(perms["permissions"]), 10) -@flaky -class PrioritiesTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - - def test_priorities(self): - priorities = self.jira.priorities() - self.assertEqual(len(priorities), 5) - - @not_on_custom_jira_instance - def test_priority(self): - priority = self.jira.priority("2") - self.assertEqual(priority.id, "2") - self.assertEqual(priority.name, "Critical") - - -@flaky -class ProjectTests(unittest.TestCase): +class SearchTests(JiraTestCase): def setUp(self): - self.jira = JiraTestManager().jira_admin - self.project_b = JiraTestManager().project_b - self.test_manager = JiraTestManager() - - def test_projects(self): - projects = self.jira.projects() - self.assertGreaterEqual(len(projects), 2) - - def test_projects_expand(self): - projects = self.jira.projects() - for project in projects: - self.assertFalse(hasattr(project, "projectKeys")) - projects = self.jira.projects(expand="projectKeys") - for project in projects: - self.assertTrue(hasattr(project, "projectKeys")) - - def test_project(self): - project = self.jira.project(self.project_b) - self.assertEqual(project.key, self.project_b) - - def test_project_expand(self): - project = self.jira.project(self.project_b) - self.assertFalse(hasattr(project, "projectKeys")) - project = self.jira.project(self.project_b, expand="projectKeys") - self.assertTrue(hasattr(project, "projectKeys")) - - # I have no idea what avatars['custom'] is and I get different results every time - # def test_project_avatars(self): - # avatars = self.jira.project_avatars(self.project_b) - # self.assertEqual(len(avatars['custom']), 3) - # self.assertEqual(len(avatars['system']), 16) - # - # def test_project_avatars_with_project_obj(self): - # project = self.jira.project(self.project_b) - # avatars = self.jira.project_avatars(project) - # self.assertEqual(len(avatars['custom']), 3) - # self.assertEqual(len(avatars['system']), 16) - - # def test_create_project_avatar(self): - # Tests the end-to-end project avatar creation process: upload as temporary, confirm after cropping, - # and selection. - # project = self.jira.project(self.project_b) - # size = os.path.getsize(TEST_ICON_PATH) - # filename = os.path.basename(TEST_ICON_PATH) - # with open(TEST_ICON_PATH, "rb") as icon: - # props = self.jira.create_temp_project_avatar(project, filename, size, icon.read()) - # self.assertIn('cropperOffsetX', props) - # self.assertIn('cropperOffsetY', props) - # self.assertIn('cropperWidth', props) - # self.assertTrue(props['needsCropping']) - # - # props['needsCropping'] = False - # avatar_props = self.jira.confirm_project_avatar(project, props) - # self.assertIn('id', avatar_props) - # - # self.jira.set_project_avatar(self.project_b, avatar_props['id']) - # - # def test_delete_project_avatar(self): - # size = os.path.getsize(TEST_ICON_PATH) - # filename = os.path.basename(TEST_ICON_PATH) - # with open(TEST_ICON_PATH, "rb") as icon: - # props = self.jira.create_temp_project_avatar(self.project_b, filename, size, icon.read(), auto_confirm=True) - # self.jira.delete_project_avatar(self.project_b, props['id']) - # - # def test_delete_project_avatar_with_project_obj(self): - # project = self.jira.project(self.project_b) - # size = os.path.getsize(TEST_ICON_PATH) - # filename = os.path.basename(TEST_ICON_PATH) - # with open(TEST_ICON_PATH, "rb") as icon: - # props = self.jira.create_temp_project_avatar(project, filename, size, icon.read(), auto_confirm=True) - # self.jira.delete_project_avatar(project, props['id']) - - # @pytest.mark.xfail(reason="Jira may return 500") - # def test_set_project_avatar(self): - # def find_selected_avatar(avatars): - # for avatar in avatars['system']: - # if avatar['isSelected']: - # return avatar - # else: - # raise Exception - # - # self.jira.set_project_avatar(self.project_b, '10001') - # avatars = self.jira.project_avatars(self.project_b) - # self.assertEqual(find_selected_avatar(avatars)['id'], '10001') - # - # project = self.jira.project(self.project_b) - # self.jira.set_project_avatar(project, '10208') - # avatars = self.jira.project_avatars(project) - # self.assertEqual(find_selected_avatar(avatars)['id'], '10208') - - def test_project_components(self): - proj = self.jira.project(self.project_b) - name = "component-%s from project %s" % (proj, rndstr()) - component = self.jira.create_component( - name, - proj, - description="test!!", - assigneeType="COMPONENT_LEAD", - isAssigneeTypeValid=False, - ) - components = self.jira.project_components(self.project_b) - self.assertGreaterEqual(len(components), 1) - sample = find_by_id(components, component.id) - self.assertEqual(sample.id, component.id) - self.assertEqual(sample.name, name) - component.delete() - - def test_project_versions(self): - name = "version-%s" % rndstr() - version = self.jira.create_version(name, self.project_b, "will be deleted soon") - versions = self.jira.project_versions(self.project_b) - self.assertGreaterEqual(len(versions), 1) - test = find_by_id(versions, version.id) - self.assertEqual(test.id, version.id) - self.assertEqual(test.name, name) - - i = self.jira.issue(JiraTestManager().project_b_issue1) - i.update(fields={"fixVersions": [{"id": version.id}]}) - version.delete() - - def test_update_project_version(self): - # given - name = "version-%s" % rndstr() - version = self.jira.create_version(name, self.project_b, "will be deleted soon") - updated_name = "version-%s" % rndstr() - # when - version.update(name=updated_name) - # then - self.assertEqual(updated_name, version.name) - version.delete() - - def test_get_project_version_by_name(self): - name = "version-%s" % rndstr() - version = self.jira.create_version(name, self.project_b, "will be deleted soon") - - found_version = self.jira.get_project_version_by_name(self.project_b, name) - self.assertEqual(found_version.id, version.id) - self.assertEqual(found_version.name, name) - - not_found_version = self.jira.get_project_version_by_name( - self.project_b, "non-existent" - ) - self.assertEqual(not_found_version, None) - - i = self.jira.issue(JiraTestManager().project_b_issue1) - i.update(fields={"fixVersions": [{"id": version.id}]}) - version.delete() - - def test_rename_version(self): - old_name = "version-%s" % rndstr() - version = self.jira.create_version( - old_name, self.project_b, "will be deleted soon" - ) - - new_name = old_name + "-renamed" - self.jira.rename_version(self.project_b, old_name, new_name) - - found_version = self.jira.get_project_version_by_name(self.project_b, new_name) - self.assertEqual(found_version.id, version.id) - self.assertEqual(found_version.name, new_name) - - not_found_version = self.jira.get_project_version_by_name( - self.project_b, old_name - ) - self.assertEqual(not_found_version, None) - - i = self.jira.issue(JiraTestManager().project_b_issue1) - i.update(fields={"fixVersions": [{"id": version.id}]}) - version.delete() - - def test_project_versions_with_project_obj(self): - name = "version-%s" % rndstr() - version = self.jira.create_version(name, self.project_b, "will be deleted soon") - project = self.jira.project(self.project_b) - versions = self.jira.project_versions(project) - self.assertGreaterEqual(len(versions), 1) - test = find_by_id(versions, version.id) - self.assertEqual(test.id, version.id) - self.assertEqual(test.name, name) - version.delete() - - @unittest.skip( - "temporary disabled because roles() return a dictionary of role_name:role_url and we have no call to convert it to proper Role()" - ) - def test_project_roles(self): - project = self.jira.project(self.project_b) - role_name = "Developers" - dev = None - for roles in [ - self.jira.project_roles(self.project_b), - self.jira.project_roles(project), - ]: - self.assertGreaterEqual(len(roles), 5) - self.assertIn("Users", roles) - self.assertIn(role_name, roles) - dev = roles[role_name] - self.assertTrue(dev) - role = self.jira.project_role(self.project_b, dev.id) - self.assertEqual(role.id, dev.id) - self.assertEqual(role.name, dev.name) - user = self.test_manager.jira_admin - self.assertNotIn(user, role.actors) - role.update(users=user, groups=["jira-developers", "jira-users"]) - role = self.jira.project_role(self.project_b, dev.id) - self.assertIn(user, role.actors) - - -@not_on_custom_jira_instance -@flaky -class ResolutionTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - - def test_resolutions(self): - resolutions = self.jira.resolutions() - self.assertGreaterEqual(len(resolutions), 1) - - def test_resolution(self): - resolution = self.jira.resolution("2") - self.assertEqual(resolution.id, "2") - self.assertEqual(resolution.name, "Won't Fix") - - -@flaky -class SearchTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - self.project_b = JiraTestManager().project_b - self.test_manager = JiraTestManager() + JiraTestCase.setUp(self) self.issue = self.test_manager.project_b_issue1 def test_search_issues(self): issues = self.jira.search_issues("project=%s" % self.project_b) + issues = cast(ResultList[Issue], issues) self.assertLessEqual(len(issues), 50) # default maxResults for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) @@ -1785,6 +201,7 @@ def test_search_issues_async(self): issues = self.jira.search_issues( "project=%s" % self.project_b, maxResults=False ) + issues = cast(ResultList[Issue], issues) self.assertEqual(len(issues), issues.total) for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) @@ -1806,6 +223,7 @@ def test_search_issues_field_limiting(self): issues = self.jira.search_issues( "key=%s" % self.issue, fields="summary,comment" ) + issues = cast(ResultList[Issue], issues) self.assertTrue(hasattr(issues[0].fields, "summary")) self.assertTrue(hasattr(issues[0].fields, "comment")) self.assertFalse(hasattr(issues[0].fields, "reporter")) @@ -1813,6 +231,7 @@ def test_search_issues_field_limiting(self): def test_search_issues_expand(self): issues = self.jira.search_issues("key=%s" % self.issue, expand="changelog") + issues = cast(ResultList[Issue], issues) # self.assertTrue(hasattr(issues[0], 'names')) self.assertEqual(len(issues), 1) self.assertFalse(hasattr(issues[0], "editmeta")) @@ -1820,329 +239,17 @@ def test_search_issues_expand(self): self.assertEqual(issues[0].key, self.issue) -@unittest.skip("Skipped due to https://jira.atlassian.com/browse/JRA-59619") -@flaky -class SecurityLevelTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - - def test_security_level(self): - # This is hardcoded due to Atlassian bug: https://jira.atlassian.com/browse/JRA-59619 - sec_level = self.jira.security_level("10000") - self.assertEqual(sec_level.id, "10000") - - -@flaky -class ServerInfoTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - +class ServerInfoTests(JiraTestCase): def test_server_info(self): server_info = self.jira.server_info() self.assertIn("baseUrl", server_info) self.assertIn("version", server_info) -@flaky -class StatusTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - - def test_statuses(self): - found = False - statuses = self.jira.statuses() - for status in statuses: - if status.name == "Done": - found = True - # find status - s = self.jira.status(status.id) - self.assertEqual(s.id, status.id) - break - self.assertTrue(found, "Status Done not found. [%s]" % statuses) - self.assertGreater(len(statuses), 0) +class OtherTests(JiraTestCase): + def setUp(self) -> None: + pass # we don't need Jira instance here - -@flaky -class StatusCategoryTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - - def test_statuscategories(self): - found = False - statuscategories = self.jira.statuscategories() - for statuscategory in statuscategories: - if statuscategory.id == 1 and statuscategory.name == u"No Category": - found = True - break - self.assertTrue( - found, "StatusCategory with id=1 not found. [%s]" % statuscategories - ) - self.assertGreater(len(statuscategories), 0) - - @flaky - def test_statuscategory(self): - statuscategory = self.jira.statuscategory(1) - self.assertEqual(statuscategory.id, 1) - self.assertEqual(statuscategory.name, "No Category") - - -@flaky -class UserTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - self.project_a = JiraTestManager().project_a - self.project_b = JiraTestManager().project_b - self.test_manager = JiraTestManager() - self.issue = self.test_manager.project_b_issue3 - - def test_user(self): - user = self.jira.user(self.test_manager.user_admin.name) - self.assertTrue(user.name) - self.assertRegex( - user.emailAddress, r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$" - ) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_assignable_users_for_projects(self): - users = self.jira.search_assignable_users_for_projects( - self.test_manager.CI_JIRA_ADMIN, "%s,%s" % (self.project_a, self.project_b) - ) - self.assertGreaterEqual(len(users), 1) - usernames = map(lambda user: user.name, users) - self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_assignable_users_for_projects_maxresults(self): - users = self.jira.search_assignable_users_for_projects( - self.test_manager.CI_JIRA_ADMIN, - "%s,%s" % (self.project_a, self.project_b), - maxResults=1, - ) - self.assertLessEqual(len(users), 1) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_assignable_users_for_projects_startat(self): - users = self.jira.search_assignable_users_for_projects( - self.test_manager.CI_JIRA_ADMIN, - "%s,%s" % (self.project_a, self.project_b), - startAt=1, - ) - self.assertGreaterEqual(len(users), 0) - - @not_on_custom_jira_instance - def test_search_assignable_users_for_issues_by_project(self): - users = self.jira.search_assignable_users_for_issues( - self.test_manager.CI_JIRA_ADMIN, project=self.project_b - ) - self.assertEqual(len(users), 1) - usernames = map(lambda user: user.name, users) - self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_assignable_users_for_issues_by_project_maxresults(self): - users = self.jira.search_assignable_users_for_issues( - self.test_manager.CI_JIRA_USER, project=self.project_b, maxResults=1 - ) - self.assertLessEqual(len(users), 1) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_assignable_users_for_issues_by_project_startat(self): - users = self.jira.search_assignable_users_for_issues( - self.test_manager.CI_JIRA_USER, project=self.project_a, startAt=1 - ) - self.assertGreaterEqual(len(users), 0) - - @not_on_custom_jira_instance - def test_search_assignable_users_for_issues_by_issue(self): - users = self.jira.search_assignable_users_for_issues( - self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue - ) - self.assertEqual(len(users), 1) - usernames = map(lambda user: user.name, users) - self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_assignable_users_for_issues_by_issue_maxresults(self): - users = self.jira.search_assignable_users_for_issues( - self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue, maxResults=2 - ) - self.assertLessEqual(len(users), 2) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_assignable_users_for_issues_by_issue_startat(self): - users = self.jira.search_assignable_users_for_issues( - self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue, startAt=2 - ) - self.assertGreaterEqual(len(users), 0) - - @pytest.mark.xfail(reason="Jira may return 500") - def test_user_avatars(self): - # Tests the end-to-end user avatar creation process: upload as temporary, confirm after cropping, - # and selection. - size = os.path.getsize(TEST_ICON_PATH) - # filename = os.path.basename(TEST_ICON_PATH) - with open(TEST_ICON_PATH, "rb") as icon: - props = self.jira.create_temp_user_avatar( - JiraTestManager().CI_JIRA_ADMIN, TEST_ICON_PATH, size, icon.read() - ) - self.assertIn("cropperOffsetX", props) - self.assertIn("cropperOffsetY", props) - self.assertIn("cropperWidth", props) - self.assertTrue(props["needsCropping"]) - - props["needsCropping"] = False - avatar_props = self.jira.confirm_user_avatar( - JiraTestManager().CI_JIRA_ADMIN, props - ) - self.assertIn("id", avatar_props) - self.assertEqual(avatar_props["owner"], JiraTestManager().CI_JIRA_ADMIN) - - self.jira.set_user_avatar(JiraTestManager().CI_JIRA_ADMIN, avatar_props["id"]) - - avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) - self.assertGreaterEqual( - len(avatars["system"]), 20 - ) # observed values between 20-24 so far - self.assertGreaterEqual(len(avatars["custom"]), 1) - - @unittest.skip("broken: set avatar returns 400") - def test_set_user_avatar(self): - def find_selected_avatar(avatars): - for avatar in avatars["system"]: - if avatar["isSelected"]: - return avatar - # else: - # raise Exception as e - # print(e) - - avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) - - self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatars["system"][0]) - avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) - self.assertEqual(find_selected_avatar(avatars)["id"], avatars["system"][0]) - - self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatars["system"][1]) - avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) - self.assertEqual(find_selected_avatar(avatars)["id"], avatars["system"][1]) - - @unittest.skip("disable until I have permissions to write/modify") - # WRONG - def test_delete_user_avatar(self): - size = os.path.getsize(TEST_ICON_PATH) - filename = os.path.basename(TEST_ICON_PATH) - with open(TEST_ICON_PATH, "rb") as icon: - props = self.jira.create_temp_user_avatar( - self.test_manager.CI_JIRA_ADMIN, filename, size, icon.read() - ) - self.jira.delete_user_avatar(self.test_manager.CI_JIRA_ADMIN, props["id"]) - - def test_search_users(self): - users = self.jira.search_users(self.test_manager.CI_JIRA_ADMIN) - self.assertGreaterEqual(len(users), 1) - usernames = map(lambda user: user.name, users) - self.assertIn(self.test_manager.user_admin.name, usernames) - - def test_search_users_maxresults(self): - users = self.jira.search_users(self.test_manager.CI_JIRA_USER, maxResults=1) - self.assertGreaterEqual(1, len(users)) - - @flaky - def test_search_allowed_users_for_issue_by_project(self): - users = self.jira.search_allowed_users_for_issue( - self.test_manager.CI_JIRA_USER, projectKey=self.project_a - ) - self.assertGreaterEqual(len(users), 1) - - @not_on_custom_jira_instance - def test_search_allowed_users_for_issue_by_issue(self): - users = self.jira.search_allowed_users_for_issue("a", issueKey=self.issue) - self.assertGreaterEqual(len(users), 1) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_allowed_users_for_issue_maxresults(self): - users = self.jira.search_allowed_users_for_issue( - "a", projectKey=self.project_b, maxResults=2 - ) - self.assertLessEqual(len(users), 2) - - @pytest.mark.xfail(reason="query returns empty list") - def test_search_allowed_users_for_issue_startat(self): - users = self.jira.search_allowed_users_for_issue( - "c", projectKey=self.project_b, startAt=1 - ) - self.assertGreaterEqual(len(users), 0) - - def test_add_users_to_set(self): - users_set = set([self.test_manager.user_admin, self.test_manager.user_admin]) - self.assertEqual(len(users_set), 1) - - -@flaky -class VersionTests(unittest.TestCase): - def setUp(self): - self.manager = JiraTestManager() - self.jira = JiraTestManager().jira_admin - self.project_b = JiraTestManager().project_b - - def test_create_version(self): - name = "new version " + self.project_b - desc = "test version of " + self.project_b - release_date = "2015-03-11" - version = self.jira.create_version( - name, self.project_b, releaseDate=release_date, description=desc - ) - self.assertEqual(version.name, name) - self.assertEqual(version.description, desc) - self.assertEqual(version.releaseDate, release_date) - version.delete() - - @flaky - def test_create_version_with_project_obj(self): - project = self.jira.project(self.project_b) - version = self.jira.create_version( - "new version 2", - project, - releaseDate="2015-03-11", - description="test version!", - ) - self.assertEqual(version.name, "new version 2") - self.assertEqual(version.description, "test version!") - self.assertEqual(version.releaseDate, "2015-03-11") - version.delete() - - @flaky - def test_update_version(self): - - version = self.jira.create_version( - "new updated version 1", - self.project_b, - releaseDate="2015-03-11", - description="new to be updated!", - ) - version.update(name="new updated version name 1", description="new updated!") - self.assertEqual(version.name, "new updated version name 1") - self.assertEqual(version.description, "new updated!") - - v = self.jira.version(version.id) - self.assertEqual(v, version) - self.assertEqual(v.id, version.id) - - version.delete() - - def test_delete_version(self): - version_str = "test_delete_version:" + self.manager.jid - version = self.jira.create_version( - version_str, - self.project_b, - releaseDate="2015-03-11", - description="not long for this world", - ) - version.delete() - self.assertRaises(JIRAError, self.jira.version, version.id) - - -@flaky -class OtherTests(unittest.TestCase): def test_session_invalid_login(self): try: JIRA( @@ -2153,6 +260,7 @@ def test_session_invalid_login(self): ) except Exception as e: self.assertIsInstance(e, JIRAError) + e = cast(JIRAError, e) # help mypy # 20161010: jira cloud returns 500 assert e.status_code in (401, 500, 403) str(JIRAError) # to see that this does not raise an exception @@ -2160,11 +268,7 @@ def test_session_invalid_login(self): assert False -@flaky -class SessionTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - +class SessionTests(JiraTestCase): def test_session(self): user = self.jira.session() self.assertIsNotNone(user.raw["self"]) @@ -2187,58 +291,7 @@ def test_session_server_offline(self): self.assertTrue(False, "Instantiation of invalid JIRA instance succeeded.") -class ListLoggingHandler(logging.Handler): - """A logging handler that records all events in a list.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.records = [] - - def emit(self, record): - self.records.append(record) - - def reset(self): - self.records = [] - - -class ResilientSessionLoggingConfidentialityTests(unittest.TestCase): - """No sensitive data shall be written to the log.""" - - def setUp(self): - self.loggingHandler = ListLoggingHandler() - jira.resilientsession.logging.getLogger().addHandler(self.loggingHandler) - - def test_logging_with_connection_error(self): - """No sensitive data shall be written to the log in case of a connection error.""" - witness = "etwhpxbhfniqnbbjoqvw" # random string; hopefully unique - for max_retries in (0, 1): - for verb in ("get", "post", "put", "delete", "head", "patch", "options"): - with self.subTest(max_retries=max_retries, verb=verb): - with jira.resilientsession.ResilientSession() as session: - session.max_retries = max_retries - session.max_retry_delay = 0 - try: - getattr(session, verb)( - "http://127.0.0.1:9", - headers={"sensitive_header": witness}, - data={"sensitive_data": witness}, - ) - except jira.resilientsession.ConnectionError: - pass - # check that `witness` does not appear in log - for record in self.loggingHandler.records: - self.assertNotIn(witness, record.msg) - for arg in record.args: - self.assertNotIn(witness, str(arg)) - self.assertNotIn(witness, str(record)) - self.loggingHandler.reset() - - def tearDown(self): - jira.resilientsession.logging.getLogger().removeHandler(self.loggingHandler) - del self.loggingHandler - - -class AsyncTests(unittest.TestCase): +class AsyncTests(JiraTestCase): def setUp(self): self.jira = JIRA( "https://jira.atlassian.com", @@ -2302,11 +355,7 @@ def _create_issue_search_results_json(issues, **kwargs): } -@flaky -class WebsudoTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - +class WebsudoTests(JiraTestCase): def test_kill_websudo(self): self.jira.kill_websudo() @@ -2314,11 +363,9 @@ def test_kill_websudo(self): # self.assertRaises(ConnectionError, JIRA) -@flaky -class UserAdministrationTests(unittest.TestCase): +class UserAdministrationTests(JiraTestCase): def setUp(self): - self.test_manager = JiraTestManager() - self.jira = self.test_manager.jira_admin + JiraTestCase.setUp(self) self.test_username = "test_%s" % self.test_manager.project_a self.test_email = "%s@example.com" % self.test_username self.test_password = rndpassword() @@ -2387,7 +434,6 @@ def test_add_and_remove_user(self): result = self.jira.delete_user(self.test_username) assert result, True - @flaky def test_add_group(self): if self._should_skip_for_pycontribs_instance(): self._skip_pycontribs_instance() @@ -2434,7 +480,7 @@ def test_remove_group(self): ) @not_on_custom_jira_instance - @pytest.mark.xfail(reason="query may return empty list") + @broken_test(reason="query may return empty list") def test_add_user_to_group(self): try: self.jira.add_user( @@ -2498,60 +544,10 @@ def test_remove_user_from_group(self): self.jira.delete_user(self.test_username) -class JiraShellTests(unittest.TestCase): +class JiraShellTests(JiraTestCase): + def setUp(self) -> None: + pass # Jira Instance not required + def test_jirashell_command_exists(self): result = os.system("jirashell --help") self.assertEqual(result, 0) - - -class JiraServiceDeskTests(unittest.TestCase): - def setUp(self): - self.jira = JiraTestManager().jira_admin - self.test_manager = JiraTestManager() - if not self.jira.supports_service_desk(): - pytest.skip("Skipping Service Desk not enabled") - - try: - self.jira.delete_project(self.test_manager.project_sd) - except Exception: - pass - - @pytest.mark.xfail(reason="Broken needs fixing") - def test_create_customer_request(self): - - self.jira.create_project( - key=self.test_manager.project_sd, - name=self.test_manager.project_sd_name, - ptype="service_desk", - template_name="IT Service Desk", - ) - service_desks = [] - for i in range(3): - service_desks = self.jira.service_desks() - if service_desks: - break - logging.warning("Service desk not reported...") - sleep(2) - self.assertTrue(service_desks, "No service desks were found!") - service_desk = service_desks[0] - - for i in range(3): - request_types = self.jira.request_types(service_desk) - if request_types: - logging.warning("Service desk request_types not reported...") - break - sleep(2) - self.assertTrue(request_types, "No request_types for service desk found!") - - request = self.jira.create_customer_request( - dict( - serviceDeskId=service_desk.id, - requestTypeId=int(request_types[0].id), - requestFieldValues=dict( - summary="Ticket title here", description="Ticket body here" - ), - ) - ) - - self.assertEqual(request.fields.summary, "Ticket title here") - self.assertEqual(request.fields.description, "Ticket body here") From 44e34d8d4239babc64e1134bf8da811ff6d64f24 Mon Sep 17 00:00:00 2001 From: Sorin Sbarnea Date: Tue, 22 Jun 2021 17:08:20 +0100 Subject: [PATCH 064/103] Refactor locking of test dependencies (#1074) Use a single constraints.txt file that is pinning all test requirements. The file is updated manually running tox -e deps. This should avoid being notified by dependabot about outdated deps, especially test ones. All projects deps are sourced from inside setup.cfg. --- .pre-commit-config.yaml | 23 ++--- bindep.txt | 4 + constraints.txt | 215 +++++++++++++++++++++++++++++++++++++++ jira/resilientsession.py | 14 +-- requirements.txt | 38 ------- setup.cfg | 20 ++++ test-requirements.in | 21 ---- test-requirements.txt | 127 ----------------------- tox.ini | 19 ++-- 9 files changed, 265 insertions(+), 216 deletions(-) create mode 100644 bindep.txt create mode 100644 constraints.txt delete mode 100644 requirements.txt delete mode 100644 test-requirements.in delete mode 100644 test-requirements.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f4bb387e..1f9c5db55 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ --- repos: - repo: https://github.com/python/black - rev: 21.4b2 + rev: 21.6b0 hooks: - id: black language_version: python3 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.0.1 hooks: - id: end-of-file-fixer - id: trailing-whitespace @@ -20,7 +20,7 @@ repos: - id: check-yaml files: .*\.(yaml|yml)$ - repo: https://github.com/codespell-project/codespell.git - rev: v2.0.0 + rev: v2.1.0 hooks: - id: codespell name: codespell @@ -32,26 +32,23 @@ repos: require_serial: false additional_dependencies: [] - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.1 + rev: 3.9.2 hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: 5.8.0 + rev: 5.9.1 hooks: - id: isort - name: isort (python) - - id: isort - name: isort (cython) - types: [cython] - - id: isort - name: isort (pyi) - types: [pyi] + name: isort - repo: https://github.com/adrienverge/yamllint.git rev: v1.26.1 hooks: - id: yamllint files: \.(yaml|yml)$ - repo: https://github.com/pre-commit/mirrors-mypy.git - rev: v0.812 + rev: v0.902 hooks: - id: mypy + additional_dependencies: + - types-requests + - types-pkg_resources diff --git a/bindep.txt b/bindep.txt new file mode 100644 index 000000000..05c9df6ca --- /dev/null +++ b/bindep.txt @@ -0,0 +1,4 @@ +gcc [platform:rpm] +krb5-devel [platform:rpm] +krb5-workstation [platform:rpm] +python3-devel [platform:rpm] diff --git a/constraints.txt b/constraints.txt new file mode 100644 index 000000000..a08cbd2c7 --- /dev/null +++ b/constraints.txt @@ -0,0 +1,215 @@ +# +# This file is autogenerated by pip-compile with python 3.6 +# To update, run: +# +# pip-compile --extra=async --extra=cli --extra=docs --extra=opt --extra=test --output-file=constraints.txt setup.cfg +# +alabaster==0.7.12 + # via sphinx +appnope==0.1.2 + # via ipython +attrs==21.2.0 + # via pytest +babel==2.9.1 + # via sphinx +backcall==0.2.0 + # via ipython +certifi==2021.5.30 + # via requests +cffi==1.14.5 + # via cryptography +chardet==4.0.0 + # via requests +coverage==5.5 + # via pytest-cov +cryptography==3.4.7 + # via requests-kerberos +decorator==5.0.9 + # via + # ipython + # traitlets +defusedxml==0.7.1 + # via jira (setup.cfg) +docutils==0.16 + # via + # jira (setup.cfg) + # sphinx + # sphinx-rtd-theme +execnet==1.9.0 + # via + # pytest-cache + # pytest-xdist +filemagic==1.6 + # via jira (setup.cfg) +flaky==3.7.0 + # via jira (setup.cfg) +idna==2.10 + # via requests +imagesize==1.2.0 + # via sphinx +importlib-metadata==4.5.0 + # via + # keyring + # pluggy + # pytest +iniconfig==1.1.1 + # via pytest +ipython==7.16.1 + # via jira (setup.cfg) +ipython-genutils==0.2.0 + # via traitlets +jedi==0.18.0 + # via ipython +jinja2==3.0.1 + # via sphinx +keyring==23.0.1 + # via jira (setup.cfg) +markupsafe==2.0.1 + # via + # jinja2 + # jira (setup.cfg) +oauthlib==3.1.1 + # via + # jira (setup.cfg) + # requests-oauthlib +packaging==20.9 + # via + # pytest + # pytest-sugar + # sphinx +parso==0.8.2 + # via jedi +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython +pluggy==0.13.1 + # via pytest +prompt-toolkit==3.0.19 + # via ipython +ptyprocess==0.7.0 + # via pexpect +py==1.10.0 + # via + # jira (setup.cfg) + # pytest + # pytest-forked +pycparser==2.20 + # via cffi +pygments==2.9.0 + # via + # ipython + # sphinx +pyjwt==2.1.0 + # via + # jira (setup.cfg) + # requests-jwt +pykerberos==1.2.1 + # via requests-kerberos +pyparsing==2.4.7 + # via packaging +pytest==6.2.4 + # via + # jira (setup.cfg) + # pytest-cache + # pytest-cov + # pytest-forked + # pytest-instafail + # pytest-sugar + # pytest-timeout + # pytest-xdist +pytest-cache==1.0 + # via jira (setup.cfg) +pytest-cov==2.12.1 + # via jira (setup.cfg) +pytest-forked==1.3.0 + # via pytest-xdist +pytest-instafail==0.4.2 + # via jira (setup.cfg) +pytest-sugar==0.9.4 + # via jira (setup.cfg) +pytest-timeout==1.4.2 + # via jira (setup.cfg) +pytest-xdist==2.3.0 + # via jira (setup.cfg) +pytz==2021.1 + # via babel +pyyaml==5.4.1 + # via jira (setup.cfg) +requests==2.25.1 + # via + # jira (setup.cfg) + # requests-futures + # requests-jwt + # requests-kerberos + # requests-mock + # requests-oauthlib + # requests-toolbelt + # requires.io + # sphinx +requests-futures==1.0.0 + # via jira (setup.cfg) +requests-jwt==0.5.3 + # via jira (setup.cfg) +requests-kerberos==0.12.0 + # via jira (setup.cfg) +requests-mock==1.9.3 + # via jira (setup.cfg) +requests-oauthlib==1.3.0 + # via jira (setup.cfg) +requests-toolbelt==0.9.1 + # via jira (setup.cfg) +requires.io==0.2.6 + # via jira (setup.cfg) +six==1.16.0 + # via + # requests-mock + # tenacity + # traitlets +snowballstemmer==2.1.0 + # via sphinx +sphinx==4.0.2 + # via + # jira (setup.cfg) + # sphinx-rtd-theme +sphinx-rtd-theme==0.5.2 + # via jira (setup.cfg) +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==2.0.0 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.5 + # via sphinx +tenacity==7.0.0 + # via jira (setup.cfg) +termcolor==1.1.0 + # via pytest-sugar +toml==0.10.2 + # via + # pytest + # pytest-cov +traitlets==4.3.3 + # via ipython +typing-extensions==3.10.0.0 + # via importlib-metadata +urllib3==1.26.5 + # via requests +wcwidth==0.2.5 + # via prompt-toolkit +wheel==0.36.2 + # via jira (setup.cfg) +xmlrunner==1.7.7 + # via jira (setup.cfg) +yanc==0.3.3 + # via jira (setup.cfg) +zipp==3.4.1 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/jira/resilientsession.py b/jira/resilientsession.py index db1225751..349c00d95 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -192,23 +192,23 @@ def __verb( response = cast(Response, response) # tell mypy only Response-like are here return response - def get(self, url: Union[str, bytes], **kwargs) -> Response: + def get(self, url: Union[str, bytes], **kwargs) -> Response: # type: ignore return self.__verb("GET", str(url), **kwargs) - def post(self, url: Union[str, bytes], data=None, json=None, **kwargs) -> Response: + def post(self, url: Union[str, bytes], data=None, json=None, **kwargs) -> Response: # type: ignore return self.__verb("POST", str(url), data=data, json=json, **kwargs) - def put(self, url: Union[str, bytes], data=None, **kwargs) -> Response: + def put(self, url: Union[str, bytes], data=None, **kwargs) -> Response: # type: ignore return self.__verb("PUT", str(url), data=data, **kwargs) - def delete(self, url: Union[str, bytes], **kwargs) -> Response: + def delete(self, url: Union[str, bytes], **kwargs) -> Response: # type: ignore return self.__verb("DELETE", str(url), **kwargs) - def head(self, url: Union[str, bytes], **kwargs) -> Response: + def head(self, url: Union[str, bytes], **kwargs) -> Response: # type: ignore return self.__verb("HEAD", str(url), **kwargs) - def patch(self, url: Union[str, bytes], data=None, **kwargs) -> Response: + def patch(self, url: Union[str, bytes], data=None, **kwargs) -> Response: # type: ignore return self.__verb("PATCH", str(url), data=data, **kwargs) - def options(self, url: Union[str, bytes], **kwargs) -> Response: + def options(self, url: Union[str, bytes], **kwargs) -> Response: # type: ignore return self.__verb("OPTIONS", str(url), **kwargs) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 42f9dcfa6..000000000 --- a/requirements.txt +++ /dev/null @@ -1,38 +0,0 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# pip-compile --extra=cli,docs,opt,async --output-file=requirements.txt setup.cfg -# -certifi==2020.12.5 - # via requests -chardet==4.0.0 - # via requests -defusedxml==0.7.1 - # via jira (setup.cfg) -idna==2.10 - # via requests -importlib-metadata==4.0.1 - # via keyring -keyring==23.0.1 - # via jira (setup.cfg) -oauthlib==3.1.0 - # via requests-oauthlib -requests-oauthlib==1.3.0 - # via jira (setup.cfg) -requests-toolbelt==0.9.1 - # via jira (setup.cfg) -requests==2.25.1 - # via - # jira (setup.cfg) - # requests-oauthlib - # requests-toolbelt -typing-extensions==3.10.0.0 - # via importlib-metadata -urllib3==1.26.4 - # via requests -zipp==3.4.1 - # via importlib-metadata - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/setup.cfg b/setup.cfg index 664ccb032..600285b78 100644 --- a/setup.cfg +++ b/setup.cfg @@ -74,6 +74,26 @@ opt = requests_kerberos async = requests-futures>=0.9.7 +test = + docutils>=0.12 + flaky + MarkupSafe>=0.23 + oauthlib + py >= 1.4 + pytest-cache + pytest-cov + pytest-instafail + pytest-sugar + pytest-timeout>=1.3.1 + pytest-xdist>=2.2 + pytest>=6.0.0,<7.0 # MIT + PyYAML>=5.1 # MIT + requests_mock # Apache-2 + requires.io # UNKNOWN!!! + tenacity # Apache-2 + wheel>=0.24.0 # MIT + xmlrunner>=1.7.7 # LGPL + yanc>=0.3.3 # GPL [options.entry_points] console_scripts = diff --git a/test-requirements.in b/test-requirements.in deleted file mode 100644 index 49e82616e..000000000 --- a/test-requirements.in +++ /dev/null @@ -1,21 +0,0 @@ -docutils>=0.12 -flaky -MarkupSafe>=0.23 -mypy -oauthlib -pre-commit # MIT -py >= 1.4 -pytest-cache -pytest-cov -pytest-instafail -pytest-sugar -pytest-timeout>=1.3.1 -pytest-xdist>=2.2 -pytest>=6.0.0,<7.0 # MIT -PyYAML>=5.1 # MIT -requests_mock # Apache-2 -requires.io # UNKNOWN!!! -tenacity # Apache-2 -wheel>=0.24.0 # MIT -xmlrunner>=1.7.7 # LGPL -yanc>=0.3.3 # GPL diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index dd0991709..000000000 --- a/test-requirements.txt +++ /dev/null @@ -1,127 +0,0 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# pip-compile --output-file=test-requirements.txt setup.cfg test-requirements.in -# -apipkg==1.5 - # via execnet -appdirs==1.4.4 - # via virtualenv -attrs==20.3.0 - # via pytest -certifi==2020.12.5 - # via requests -cfgv==3.2.0 - # via pre-commit -chardet==4.0.0 - # via requests -coverage[toml]==5.5 - # via pytest-cov -distlib==0.3.1 - # via virtualenv -docutils==0.17.1 - # via -r test-requirements.in -execnet==1.8.0 - # via - # pytest-cache - # pytest-xdist -filelock==3.0.12 - # via virtualenv -flaky==3.7.0 - # via -r test-requirements.in -identify==2.2.4 - # via pre-commit -idna==2.10 - # via requests -iniconfig==1.1.1 - # via pytest -markupsafe==2.0.1 - # via -r test-requirements.in -mypy-extensions==0.4.3 - # via mypy -mypy==0.812 - # via -r test-requirements.in -nodeenv==1.6.0 - # via pre-commit -oauthlib==3.1.0 - # via -r test-requirements.in -packaging==20.9 - # via - # pytest - # pytest-sugar -pluggy==0.13.1 - # via pytest -pre-commit==2.13.0 - # via -r test-requirements.in -py==1.10.0 - # via - # -r test-requirements.in - # pytest - # pytest-forked -pyparsing==2.4.7 - # via packaging -pytest-cache==1.0 - # via -r test-requirements.in -pytest-cov==2.12.0 - # via -r test-requirements.in -pytest-forked==1.3.0 - # via pytest-xdist -pytest-instafail==0.4.2 - # via -r test-requirements.in -pytest-sugar==0.9.4 - # via -r test-requirements.in -pytest-timeout==1.4.2 - # via -r test-requirements.in -pytest-xdist==2.2.1 - # via -r test-requirements.in -pytest==6.2.4 - # via - # -r test-requirements.in - # pytest-cache - # pytest-cov - # pytest-forked - # pytest-instafail - # pytest-sugar - # pytest-timeout - # pytest-xdist -pyyaml==5.4.1 - # via - # -r test-requirements.in - # pre-commit -requests-mock==1.9.2 - # via -r test-requirements.in -requests==2.25.1 - # via - # requests-mock - # requires.io -requires.io==0.2.6 - # via -r test-requirements.in -six==1.15.0 - # via - # requests-mock - # tenacity - # virtualenv -tenacity==7.0.0 - # via -r test-requirements.in -termcolor==1.1.0 - # via pytest-sugar -toml==0.10.2 - # via - # coverage - # pre-commit - # pytest -typed-ast==1.4.3 - # via mypy -typing-extensions==3.10.0.0 - # via mypy -urllib3==1.26.4 - # via requests -virtualenv==20.4.4 - # via pre-commit -wheel==0.36.2 - # via -r test-requirements.in -xmlrunner==1.7.7 - # via -r test-requirements.in -yanc==0.3.3 - # via -r test-requirements.in diff --git a/tox.ini b/tox.ini index c91a9d648..491f8fe39 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] minversion = 3.8.0 requires = + tox-extra tox-pyenv envlist = py39 @@ -26,9 +27,7 @@ usedevelop = True extras = cli opt -deps = - -r requirements.txt - -r test-requirements.txt + test sitepackages=False commands= git clean -xdf jira tests @@ -36,6 +35,7 @@ commands= python make_local_jira_user.py python -m pytest {posargs} setenv = + PIP_CONSTRAINT={toxinidir}/constraints.txt PIP_LOG={envdir}/pip.log PIP_DISABLE_PIP_VERSION_CHECK=1 # Avoid 2020-01-01 warnings: https://github.com/pypa/pip/issues/6207 @@ -58,9 +58,6 @@ passenv = XDG_CACHE_HOME # For Windows users, getpass.get_user() needs USERNAME USERNAME -envars = - PIP_DISABLE_PIP_VERSION_CHECK=1 - PIP_USER=no whitelist_externals = git @@ -70,10 +67,11 @@ description = Update dependency lock files # to get pinning correctly. basepython = python3.6 deps = - pip-tools >= 6.1.0 + pip-tools >= 6.2.0 + pre-commit >= 2.13.0 commands = - pip-compile -o requirements.txt setup.cfg --extra cli,docs,opt,async - pip-compile -o test-requirements.txt setup.cfg test-requirements.in + pip-compile --upgrade -o constraints.txt setup.cfg --extra cli --extra docs --extra opt --extra async --extra test + {envpython} -m pre_commit autoupdate [testenv:docs] extras = @@ -115,7 +113,8 @@ usedevelop = false deps = pre-commit>=1.17.0 commands= python -m pre_commit run --color=always {posargs:--all} -extras = +setenv = + PIP_CONSTRAINT=/dev/null skip_install = true usedevelop = false From 676045564b3b1ede4dbe26658da04db0838b134b Mon Sep 17 00:00:00 2001 From: Julen <12843626+julenpardo@users.noreply.github.com> Date: Tue, 22 Jun 2021 19:54:16 +0200 Subject: [PATCH 065/103] Add (some) missing type hints for `_IssueFields` (#1063) * Add missing type hints * Add type for _raw after parsing * Define type for raw for every resource * Subclass `Any` for _IssueFields when running type checking * Nitpick ignore MyAny --- docs/conf.py | 1 + jira/resources.py | 58 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 720fe07ff..058643cf1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,6 +57,7 @@ nitpick_ignore = [ ("py:class", "JIRA"), # in jira.resources we only import this class if type ("py:obj", "typing.ResourceType"), # only Py36 has a problem with this reference + ("py:class", "jira.resources.MyAny"), # Dummy subclass for type checking # From other packages ("py:mod", "filemagic"), ("py:mod", "ipython"), diff --git a/jira/resources.py b/jira/resources.py index e091cf9d8..faf43b3f0 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -18,6 +18,15 @@ if TYPE_CHECKING: from jira.client import JIRA + MyAny = Any +else: + + class MyAny(object): + """Dummy subclass of base object class for when type checker is not running.""" + + pass + + __all__ = ( "Resource", "Issue", @@ -486,6 +495,7 @@ def __init__( Resource.__init__(self, "attachment/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def get(self): """Return the file content as a string.""" @@ -510,6 +520,7 @@ def __init__( Resource.__init__(self, "component/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def delete(self, moveIssuesTo: Optional[str] = None): # type: ignore[override] """Delete this component from the server. @@ -536,6 +547,7 @@ def __init__( Resource.__init__(self, "customFieldOption/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Dashboard(Resource): @@ -550,6 +562,7 @@ def __init__( Resource.__init__(self, "dashboard/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Filter(Resource): @@ -564,12 +577,13 @@ def __init__( Resource.__init__(self, "filter/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Issue(Resource): """A Jira issue.""" - class _IssueFields(object): + class _IssueFields(MyAny): class _Comment(object): def __init__(self) -> None: self.comments: List[Comment] = [] @@ -579,12 +593,27 @@ def __init__(self) -> None: self.worklogs: List[Worklog] = [] def __init__(self): + self.assignee: Optional[UnknownResource] = None self.attachment: List[Attachment] = [] self.comment = self._Comment() + self.created: str self.description: Optional[str] = None + self.duedate: Optional[str] = None self.issuelinks: List[IssueLink] = [] + self.issuetype: IssueType self.labels: List[str] = [] - self.project: Optional[Project] = None + self.priority: Priority + self.project: Project + self.reporter: UnknownResource + self.resolution: Optional[Resolution] = None + self.security: Optional[SecurityLevel] = None + self.status: Status + self.statuscategorychangedate: Optional[str] = None + self.summary: str + self.timetracking: TimeTracking + self.versions: List[Version] = [] + self.votes: Votes + self.watchers: Watchers self.worklog = self._Worklog() def __init__( @@ -600,6 +629,7 @@ def __init__( self.key: str 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, @@ -704,6 +734,7 @@ def __init__( Resource.__init__(self, "issue/{0}/comment/{1}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def update(self, fields=None, async_=None, jira=None, body="", visibility=None): """Update a comment""" @@ -727,6 +758,7 @@ def __init__( Resource.__init__(self, "issue/{0}/remotelink/{1}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def update(self, object, globalId=None, application=None, relationship=None): """Update a RemoteLink. 'object' is required. @@ -763,6 +795,7 @@ def __init__( Resource.__init__(self, "issue/{0}/votes", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Watchers(Resource): @@ -777,6 +810,7 @@ def __init__( Resource.__init__(self, "issue/{0}/watchers", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def delete(self, username): """Remove the specified user from the watchers list.""" @@ -794,6 +828,7 @@ def __init__( self.remainingEstimate = None if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Worklog(Resource): @@ -808,6 +843,7 @@ def __init__( Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def delete( # type: ignore[override] self, adjustEstimate: Optional[str] = None, newEstimate=None, increaseBy=None @@ -844,6 +880,7 @@ def __init__( Resource.__init__(self, "issueLink/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class IssueLinkType(Resource): @@ -858,6 +895,7 @@ def __init__( Resource.__init__(self, "issueLinkType/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class IssueType(Resource): @@ -872,6 +910,7 @@ def __init__( Resource.__init__(self, "issuetype/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Priority(Resource): @@ -886,6 +925,7 @@ def __init__( Resource.__init__(self, "priority/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Project(Resource): @@ -900,6 +940,7 @@ def __init__( Resource.__init__(self, "project/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Role(Resource): @@ -914,6 +955,7 @@ def __init__( Resource.__init__(self, "project/{0}/role/{1}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def update( # type: ignore[override] self, @@ -977,6 +1019,7 @@ def __init__( Resource.__init__(self, "resolution/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class SecurityLevel(Resource): @@ -991,6 +1034,7 @@ def __init__( Resource.__init__(self, "securitylevel/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Status(Resource): @@ -1005,6 +1049,7 @@ def __init__( Resource.__init__(self, "status/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class StatusCategory(Resource): @@ -1019,6 +1064,7 @@ def __init__( Resource.__init__(self, "statuscategory/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class User(Resource): @@ -1033,6 +1079,7 @@ def __init__( Resource.__init__(self, "user?username={0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Group(Resource): @@ -1047,6 +1094,7 @@ def __init__( Resource.__init__(self, "group?groupname={0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Version(Resource): @@ -1061,6 +1109,7 @@ def __init__( Resource.__init__(self, "version/{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): """ @@ -1144,6 +1193,7 @@ def __init__( # Old GreenHopper API did not contain self - create it for backward compatibility. if not self.self: self.self = self._get_url(path.format(raw["id"])) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class Sprint(GreenHopperResource): @@ -1214,6 +1264,7 @@ def __init__( ) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class ServiceDesk(Resource): @@ -1234,6 +1285,7 @@ def __init__( ) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) class RequestType(Resource): @@ -1247,6 +1299,7 @@ def __init__( ): if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) Resource.__init__( self, @@ -1363,6 +1416,7 @@ def __init__( Resource.__init__(self, "unknown{0}", options, session) if raw: self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) def cls_for_resource(resource_literal: str) -> Type[Resource]: From 8c6719dce8f4e545459a82b486d24b14d15cd2d2 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sun, 27 Jun 2021 21:22:55 +0100 Subject: [PATCH 066/103] add jira-version to job matrix, bump CI Jira to 8.17.1 (#1086) --- .github/workflows/jira_server_ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/jira_server_ci.yml b/.github/workflows/jira_server_ci.yml index 400cbb1c7..8e6a97f4b 100644 --- a/.github/workflows/jira_server_ci.yml +++ b/.github/workflows/jira_server_ci.yml @@ -12,17 +12,18 @@ on: jobs: test: - name: ${{ matrix.os }} / ${{ matrix.python-version }} + name: ${{ matrix.os }} / Python ${{ matrix.python-version }} / Jira ${{ matrix.jira-version }} runs-on: ${{ matrix.os }}-latest strategy: matrix: os: [Ubuntu] python-version: [3.6, 3.7, 3.8, 3.9] + jira-version: [8.17.1] steps: - uses: actions/checkout@master - name: Start Jira docker instance - run: docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone + run: docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone --version ${{ matrix.jira-version }} - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 From f763075c49cc687fdc70f58a85f5e3e64c306d80 Mon Sep 17 00:00:00 2001 From: Mancang Date: Sat, 10 Jul 2021 23:06:24 +0800 Subject: [PATCH 067/103] Fix broken tests (#1079) * fixed tests Changes: * fixed group_members() * fixed project_roles() * fixed create_board() * fixed # tests, new tests result: 165 passed, 4 xfailed, 1 skipped test_dashboard.py: 2 xfailed test_security_level.py: 2 xfailed test_service_desk.py: 1 skipped there 5 cases are expected, they are all because of the standalone jira instance from the docker image has no these data defined by default. To make them pass, need to create dashboard or security schema, unfortunately, there is no REST API to create them. * Fixed review issues Revert most of changes made in the library itself. With this change, 1 more test xfailed: tests/resources/test_issue.py::IssueTests::test_agile, this fail is because GreenHopper returns error when creating sprint on standalone jira docker image. Latest tests results: 164 passed, 6 xfailed, 1 skipped --- jira/client.py | 14 ++- tests/resources/test_custom_field_option.py | 5 +- tests/resources/test_dashboard.py | 17 +-- tests/resources/test_group.py | 9 +- tests/resources/test_issue.py | 116 ++++++++++---------- tests/resources/test_priority.py | 5 +- tests/resources/test_project.py | 45 ++++---- tests/resources/test_remote_link.py | 110 ++++++++++++------- tests/resources/test_resolution.py | 9 +- tests/resources/test_security_level.py | 4 +- tests/resources/test_user.py | 37 +++---- tests/tests.py | 25 ++--- 12 files changed, 208 insertions(+), 188 deletions(-) diff --git a/jira/client.py b/jira/client.py index 88b4dc452..6f281f2ac 100644 --- a/jira/client.py +++ b/jira/client.py @@ -1232,7 +1232,17 @@ def group_members(self, group: str) -> OrderedDict: result = {} for user in r["users"]["items"]: - result[user["id"]] = { + # 'id' is likely available only in older JIRA Server, it's not available on newer JIRA Server. + # 'name' is not available in JIRA Cloud. + 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") + ] = { "name": user.get("name"), "id": user.get("id"), "accountId": user.get("accountId"), @@ -1800,7 +1810,7 @@ def add_remote_link( ``destination`` should be a dict containing at least ``url`` to the linked external URL and ``title`` to display for the link inside Jira. - For definitions of the allowable fields for ``object`` and the keyword arguments ``globalId``, ``application`` + For definitions of the allowable fields for ``destination`` and the keyword arguments ``globalId``, ``application`` and ``relationship``, see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links. Args: diff --git a/tests/resources/test_custom_field_option.py b/tests/resources/test_custom_field_option.py index cd2908193..273da848a 100644 --- a/tests/resources/test_custom_field_option.py +++ b/tests/resources/test_custom_field_option.py @@ -1,8 +1,7 @@ -from tests.conftest import JiraTestCase, not_on_custom_jira_instance +from tests.conftest import JiraTestCase class CustomFieldOptionTests(JiraTestCase): - @not_on_custom_jira_instance def test_custom_field_option(self): - option = self.jira.custom_field_option("10001") + option = self.jira.custom_field_option("10000") self.assertEqual(option.value, "To Do") diff --git a/tests/resources/test_dashboard.py b/tests/resources/test_dashboard.py index 76faddddb..7a0b0af79 100644 --- a/tests/resources/test_dashboard.py +++ b/tests/resources/test_dashboard.py @@ -1,19 +1,21 @@ -from tests.conftest import JiraTestCase, not_on_custom_jira_instance +from tests.conftest import JiraTestCase, broken_test -@not_on_custom_jira_instance class DashboardTests(JiraTestCase): def test_dashboards(self): dashboards = self.jira.dashboards() - self.assertEqual(len(dashboards), 3) + self.assertGreaterEqual(len(dashboards), 1) + @broken_test( + reason="standalone jira docker image has only 1 system dashboard by default" + ) def test_dashboards_filter(self): dashboards = self.jira.dashboards(filter="my") self.assertEqual(len(dashboards), 2) self.assertEqual(dashboards[0].id, "10101") def test_dashboards_startat(self): - dashboards = self.jira.dashboards(startAt=1, maxResults=1) + dashboards = self.jira.dashboards(startAt=0, maxResults=1) self.assertEqual(len(dashboards), 1) def test_dashboards_maxresults(self): @@ -21,6 +23,7 @@ def test_dashboards_maxresults(self): self.assertEqual(len(dashboards), 1) def test_dashboard(self): - dashboard = self.jira.dashboard("10101") - self.assertEqual(dashboard.id, "10101") - self.assertEqual(dashboard.name, "Another test dashboard") + expected_ds = self.jira.dashboards()[0] + dashboard = self.jira.dashboard(expected_ds.id) + self.assertEqual(dashboard.id, expected_ds.id) + self.assertEqual(dashboard.name, expected_ds.name) diff --git a/tests/resources/test_group.py b/tests/resources/test_group.py index d92187c79..8be77dd2a 100644 --- a/tests/resources/test_group.py +++ b/tests/resources/test_group.py @@ -1,16 +1,15 @@ -from tests.conftest import JiraTestCase, not_on_custom_jira_instance +from tests.conftest import JiraTestCase -@not_on_custom_jira_instance class GroupsTest(JiraTestCase): def test_group(self): - group = self.jira.group("jira-users") - self.assertEqual(group.name, "jira-users") + group = self.jira.group("jira-administrators") + self.assertEqual(group.name, "jira-administrators") def test_groups(self): groups = self.jira.groups() self.assertGreater(len(groups), 0) def test_groups_for_users(self): - groups = self.jira.groups("jira-users") + groups = self.jira.groups("jira-administrators") self.assertGreater(len(groups), 0) diff --git a/tests/resources/test_issue.py b/tests/resources/test_issue.py index 1587d31c2..ca7bcbcd0 100644 --- a/tests/resources/test_issue.py +++ b/tests/resources/test_issue.py @@ -8,7 +8,6 @@ broken_test, find_by_key, find_by_key_value, - not_on_custom_jira_instance, rndstr, ) @@ -27,7 +26,6 @@ def test_issue(self): self.assertEqual(issue.key, self.issue_1) self.assertEqual(issue.fields.summary, "issue 1 from %s" % self.project_b) - @broken_test(reason="disabled as it seems to be ignored by jira, returning all") def test_issue_field_limiting(self): issue = self.jira.issue(self.issue_2, fields="summary,comment") self.assertEqual(issue.fields.summary, "issue 2 from %s" % self.project_b) @@ -57,13 +55,12 @@ def test_issue_expand(self): # testing for changelog is not reliable because it may exist or not based on test order # self.assertFalse(hasattr(issue, 'changelog')) - @not_on_custom_jira_instance def test_create_issue_with_fieldargs(self): issue = self.jira.create_issue( - project=self.project_b, summary="Test issue created", - description="foo description", + project=self.project_b, issuetype={"name": "Bug"}, + description="foo description", ) # customfield_10022='XSS' self.assertEqual(issue.fields.summary, "Test issue created") self.assertEqual(issue.fields.description, "foo description") @@ -72,15 +69,14 @@ def test_create_issue_with_fieldargs(self): # self.assertEqual(issue.fields.customfield_10022, 'XSS') issue.delete() - @not_on_custom_jira_instance def test_create_issue_with_fielddict(self): fields = { - "project": {"key": self.project_b}, "summary": "Issue created from field dict", - "description": "Some new issue for test", + "project": {"key": self.project_b}, "issuetype": {"name": "Bug"}, + "description": "Some new issue for test", # 'customfield_10022': 'XSS', - "priority": {"name": "Major"}, + "priority": {"name": "High"}, } issue = self.jira.create_issue(fields=fields) self.assertEqual(issue.fields.summary, "Issue created from field dict") @@ -88,17 +84,16 @@ def test_create_issue_with_fielddict(self): self.assertEqual(issue.fields.issuetype.name, "Bug") self.assertEqual(issue.fields.project.key, self.project_b) # self.assertEqual(issue.fields.customfield_10022, 'XSS') - self.assertEqual(issue.fields.priority.name, "Major") + self.assertEqual(issue.fields.priority.name, "High") issue.delete() - @not_on_custom_jira_instance def test_create_issue_without_prefetch(self): issue = self.jira.create_issue( - prefetch=False, - project=self.project_b, summary="Test issue created", - description="some details", + project=self.project_b, issuetype={"name": "Bug"}, + description="some details", + prefetch=False, ) # customfield_10022='XSS' assert hasattr(issue, "self") @@ -106,23 +101,22 @@ def test_create_issue_without_prefetch(self): assert "fields" not in issue.raw issue.delete() - @not_on_custom_jira_instance def test_create_issues(self): field_list = [ { - "project": {"key": self.project_b}, "summary": "Issue created via bulk create #1", - "description": "Some new issue for test", + "project": {"key": self.project_b}, "issuetype": {"name": "Bug"}, + "description": "Some new issue for test", # 'customfield_10022': 'XSS', - "priority": {"name": "Major"}, + "priority": {"name": "High"}, }, { + "summary": "Issue created via bulk create #2", "project": {"key": self.project_a}, "issuetype": {"name": "Bug"}, - "summary": "Issue created via bulk create #2", "description": "Another new issue for bulk test", - "priority": {"name": "Major"}, + "priority": {"name": "High"}, }, ] issues = self.jira.create_issues(field_list=field_list) @@ -136,7 +130,7 @@ def test_create_issues(self): ) self.assertEqual(issues[0]["issue"].fields.issuetype.name, "Bug") self.assertEqual(issues[0]["issue"].fields.project.key, self.project_b) - self.assertEqual(issues[0]["issue"].fields.priority.name, "Major") + self.assertEqual(issues[0]["issue"].fields.priority.name, "High") self.assertIsNotNone( issues[1]["issue"], "the second issue has not been created" ) @@ -148,34 +142,33 @@ def test_create_issues(self): ) self.assertEqual(issues[1]["issue"].fields.issuetype.name, "Bug") self.assertEqual(issues[1]["issue"].fields.project.key, self.project_a) - self.assertEqual(issues[1]["issue"].fields.priority.name, "Major") + self.assertEqual(issues[1]["issue"].fields.priority.name, "High") for issue in issues: issue["issue"].delete() - @not_on_custom_jira_instance def test_create_issues_one_failure(self): field_list = [ { - "project": {"key": self.project_b}, "summary": "Issue created via bulk create #1", - "description": "Some new issue for test", + "project": {"key": self.project_b}, "issuetype": {"name": "Bug"}, + "description": "Some new issue for test", # 'customfield_10022': 'XSS', - "priority": {"name": "Major"}, + "priority": {"name": "High"}, }, { + "summary": "This issue will not succeed", "project": {"key": self.project_a}, "issuetype": {"name": "InvalidIssueType"}, - "summary": "This issue will not succeed", "description": "Should not be seen.", - "priority": {"name": "Blah"}, + "priority": {"name": "High"}, }, { + "summary": "However, this one will.", "project": {"key": self.project_a}, "issuetype": {"name": "Bug"}, - "summary": "However, this one will.", "description": "Should be seen.", - "priority": {"name": "Major"}, + "priority": {"name": "High"}, }, ] issues = self.jira.create_issues(field_list=field_list) @@ -187,7 +180,7 @@ def test_create_issues_one_failure(self): ) self.assertEqual(issues[0]["issue"].fields.issuetype.name, "Bug") self.assertEqual(issues[0]["issue"].fields.project.key, self.project_b) - self.assertEqual(issues[0]["issue"].fields.priority.name, "Major") + self.assertEqual(issues[0]["issue"].fields.priority.name, "High") self.assertEqual(issues[0]["error"], None) self.assertEqual(issues[1]["issue"], None) self.assertEqual(issues[1]["error"], {"issuetype": "issue type is required"}) @@ -196,27 +189,26 @@ def test_create_issues_one_failure(self): self.assertEqual(issues[2]["issue"].fields.description, "Should be seen.") self.assertEqual(issues[2]["issue"].fields.issuetype.name, "Bug") self.assertEqual(issues[2]["issue"].fields.project.key, self.project_a) - self.assertEqual(issues[2]["issue"].fields.priority.name, "Major") + self.assertEqual(issues[2]["issue"].fields.priority.name, "High") self.assertEqual(issues[2]["error"], None) self.assertEqual(len(issues), 3) for issue in issues: if issue["issue"] is not None: issue["issue"].delete() - @not_on_custom_jira_instance def test_create_issues_without_prefetch(self): field_list = [ dict( + summary="Test issue #1 created with dicts without prefetch", project=self.project_b, - summary="Test issue created", - description="some details", issuetype={"name": "Bug"}, + description="some details", ), dict( + summary="Test issue #2 created with dicts without prefetch", project=self.project_a, - summary="Test issue #2", - description="foo description", issuetype={"name": "Bug"}, + description="foo description", ), ] issues = self.jira.create_issues(field_list, prefetch=False) @@ -230,54 +222,52 @@ def test_create_issues_without_prefetch(self): for issue in issues: issue["issue"].delete() - @not_on_custom_jira_instance def test_update_with_fieldargs(self): issue = self.jira.create_issue( + summary="Test issue for updating with fieldargs", project=self.project_b, - summary="Test issue for updating", - description="Will be updated shortly", issuetype={"name": "Bug"}, + description="Will be updated shortly", ) # customfield_10022='XSS') issue.update( summary="Updated summary", description="Now updated", - issuetype={"name": "Story"}, + issuetype={"name": "Task"}, ) self.assertEqual(issue.fields.summary, "Updated summary") self.assertEqual(issue.fields.description, "Now updated") - self.assertEqual(issue.fields.issuetype.name, "Story") + self.assertEqual(issue.fields.issuetype.name, "Task") # self.assertEqual(issue.fields.customfield_10022, 'XSS') self.assertEqual(issue.fields.project.key, self.project_b) issue.delete() - @not_on_custom_jira_instance def test_update_with_fielddict(self): issue = self.jira.create_issue( + summary="Test issue for updating with fielddict", project=self.project_b, - summary="Test issue for updating", description="Will be updated shortly", issuetype={"name": "Bug"}, ) fields = { "summary": "Issue is updated", "description": "it sure is", - "issuetype": {"name": "Story"}, + "issuetype": {"name": "Task"}, # 'customfield_10022': 'DOC', - "priority": {"name": "Major"}, + "priority": {"name": "High"}, } issue.update(fields=fields) self.assertEqual(issue.fields.summary, "Issue is updated") self.assertEqual(issue.fields.description, "it sure is") - self.assertEqual(issue.fields.issuetype.name, "Story") + self.assertEqual(issue.fields.issuetype.name, "Task") # self.assertEqual(issue.fields.customfield_10022, 'DOC') - self.assertEqual(issue.fields.priority.name, "Major") + self.assertEqual(issue.fields.priority.name, "High") issue.delete() def test_update_with_label(self): issue = self.jira.create_issue( - project=self.project_b, summary="Test issue for updating labels", + project=self.project_b, description="Label testing", issuetype=self.test_manager.CI_JIRA_ISSUE, ) @@ -290,8 +280,8 @@ def test_update_with_label(self): def test_update_with_bad_label(self): issue = self.jira.create_issue( + summary="Test issue for updating bad labels", project=self.project_b, - summary="Test issue for updating labels", description="Label testing", issuetype=self.test_manager.CI_JIRA_ISSUE, ) @@ -302,11 +292,10 @@ def test_update_with_bad_label(self): self.assertRaises(JIRAError, issue.update, fields=fields) - @not_on_custom_jira_instance def test_update_with_notify_false(self): issue = self.jira.create_issue( + summary="Test issue for updating wiith notify false", project=self.project_b, - summary="Test issue for updating", description="Will be updated shortly", issuetype={"name": "Bug"}, ) @@ -316,8 +305,8 @@ def test_update_with_notify_false(self): def test_delete(self): issue = self.jira.create_issue( - project=self.project_b, summary="Test issue created", + project=self.project_b, description="Not long for this world", issuetype=self.test_manager.CI_JIRA_ISSUE, ) @@ -325,29 +314,25 @@ def test_delete(self): issue.delete() self.assertRaises(JIRAError, self.jira.issue, key) - @not_on_custom_jira_instance def test_createmeta(self): meta = self.jira.createmeta() proj = find_by_key(meta["projects"], self.project_b) # we assume that this project should allow at least one issue type self.assertGreaterEqual(len(proj["issuetypes"]), 1) - @not_on_custom_jira_instance def test_createmeta_filter_by_projectkey_and_name(self): meta = self.jira.createmeta(projectKeys=self.project_b, issuetypeNames="Bug") self.assertEqual(len(meta["projects"]), 1) self.assertEqual(len(meta["projects"][0]["issuetypes"]), 1) - @not_on_custom_jira_instance def test_createmeta_filter_by_projectkeys_and_name(self): meta = self.jira.createmeta( - projectKeys=(self.project_a, self.project_b), issuetypeNames="Story" + projectKeys=(self.project_a, self.project_b), issuetypeNames="Task" ) self.assertEqual(len(meta["projects"]), 2) for project in meta["projects"]: self.assertEqual(len(project["issuetypes"]), 1) - @not_on_custom_jira_instance def test_createmeta_filter_by_id(self): projects = self.jira.projects() proja = find_by_key_value(projects, self.project_a) @@ -461,19 +446,26 @@ def test_transitioning(self): # self.assertEqual(issue.fields.assignee.name, self.test_manager.CI_JIRA_USER) # self.assertEqual(issue.fields.status.id, transition_id) - @not_on_custom_jira_instance + @broken_test( + reason="Greenhopper API doesn't work on standalone docker image with JIRA Server 8.9.0" + ) def test_agile(self): uniq = rndstr() board_name = "board-" + uniq sprint_name = "sprint-" + uniq + filter_name = "filter-" + uniq - b = self.jira.create_board(board_name, self.project_a) + filter = self.jira.create_filter( + filter_name, "description", f"project={self.project_b}", True + ) + + b = self.jira.create_board(board_name, filter.id) assert isinstance(b.id, int) s = self.jira.create_sprint(sprint_name, b.id) assert isinstance(s.id, int) assert s.name == sprint_name - assert s.state == "FUTURE" + assert s.state.upper() == "FUTURE" self.jira.add_issues_to_sprint(s.id, [self.issue_1]) @@ -501,3 +493,5 @@ def test_agile(self): sleep(2) b.delete() # self.jira.delete_board(b.id) + + filter.delete() # must delete this filter AFTER deleting board referencing the filter diff --git a/tests/resources/test_priority.py b/tests/resources/test_priority.py index 032041fe3..639d05ec7 100644 --- a/tests/resources/test_priority.py +++ b/tests/resources/test_priority.py @@ -1,4 +1,4 @@ -from tests.conftest import JiraTestCase, not_on_custom_jira_instance +from tests.conftest import JiraTestCase class PrioritiesTests(JiraTestCase): @@ -6,8 +6,7 @@ def test_priorities(self): priorities = self.jira.priorities() self.assertEqual(len(priorities), 5) - @not_on_custom_jira_instance def test_priority(self): priority = self.jira.priority("2") self.assertEqual(priority.id, "2") - self.assertEqual(priority.name, "Critical") + self.assertEqual(priority.name, "High") diff --git a/tests/resources/test_project.py b/tests/resources/test_project.py index 05f4e4a82..224f47814 100644 --- a/tests/resources/test_project.py +++ b/tests/resources/test_project.py @@ -1,4 +1,4 @@ -from tests.conftest import JiraTestCase, broken_test, find_by_id, rndstr +from tests.conftest import JiraTestCase, find_by_id, rndstr class ProjectTests(JiraTestCase): @@ -179,27 +179,24 @@ def test_project_versions_with_project_obj(self): self.assertEqual(test.name, name) version.delete() - @broken_test( - reason="temporary disabled because roles() return a dictionary of role_name:role_url and we have no call to convert it to proper Role()" - ) def test_project_roles(self): - project = self.jira.project(self.project_b) - role_name = "Developers" - dev = None - for roles in [ - self.jira.project_roles(self.project_b), - self.jira.project_roles(project), - ]: - self.assertGreaterEqual(len(roles), 5) - self.assertIn("Users", roles) - self.assertIn(role_name, roles) - dev = roles[role_name] - self.assertTrue(dev) - role = self.jira.project_role(self.project_b, dev.id) - self.assertEqual(role.id, dev.id) - self.assertEqual(role.name, dev.name) - user = self.test_manager.jira_admin - self.assertNotIn(user, role.actors) - role.update(users=user, groups=["jira-developers", "jira-users"]) - role = self.jira.project_role(self.project_b, dev.id) - self.assertIn(user, role.actors) + role_name = "Administrators" + admin = None + roles = self.jira.project_roles(self.project_b) + self.assertGreaterEqual(len(roles), 1) + self.assertIn(role_name, roles) + admin = roles[role_name] + self.assertTrue(admin) + role = self.jira.project_role(self.project_b, admin["id"]) + self.assertEqual(role.id, int(admin["id"])) + + actornames = {actor.name: actor for actor in role.actors} + actor_admin = "jira-administrators" + self.assertIn(actor_admin, actornames) + members = self.jira.group_members(actor_admin) + user = self.user_admin + self.assertIn(user.name, members.keys()) + role.update(users=user.name, groups=actor_admin) + role = self.jira.project_role(self.project_b, int(admin["id"])) + self.assertIn(user.name, [a.name for a in role.actors]) + self.assertIn(actor_admin, [a.name for a in role.actors]) diff --git a/tests/resources/test_remote_link.py b/tests/resources/test_remote_link.py index 3a89eb07f..bb9c28b60 100644 --- a/tests/resources/test_remote_link.py +++ b/tests/resources/test_remote_link.py @@ -1,119 +1,145 @@ from jira.exceptions import JIRAError -from tests.conftest import JiraTestCase, broken_test +from tests.conftest import JiraTestCase + +DEFAULT_NEW_REMOTE_LINK_OBJECT = {"url": "http://google.com", "title": "googlicious!"} -@broken_test(reason="Nothing from remote link works") class RemoteLinkTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue_1 = self.test_manager.project_b_issue1 self.issue_2 = self.test_manager.project_b_issue2 self.issue_3 = self.test_manager.project_b_issue3 + self.project_b_issue1_obj = self.test_manager.project_b_issue1_obj def test_remote_links(self): self.jira.add_remote_link( - "ZTRAVISDEB-3", globalId="python-test:story.of.horse.riding" + self.issue_1, + destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, ) - links = self.jira.remote_links("QA-44") + links = self.jira.remote_links(self.issue_1) self.assertEqual(len(links), 1) - links = self.jira.remote_links("BULK-1") + self.jira.remote_link(self.issue_1, links[0].id).delete() + links = self.jira.remote_links(self.issue_2) self.assertEqual(len(links), 0) - @broken_test(reason="temporary disabled") def test_remote_links_with_issue_obj(self): - issue = self.jira.issue("QA-44") - links = self.jira.remote_links(issue) + self.jira.add_remote_link( + self.issue_1, + destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, + ) + links = self.jira.remote_links(self.project_b_issue1_obj) self.assertEqual(len(links), 1) - issue = self.jira.issue("BULK-1") - links = self.jira.remote_links(issue) + self.jira.remote_link(self.issue_1, links[0].id).delete() + links = self.jira.remote_links(self.project_b_issue1_obj) self.assertEqual(len(links), 0) - @broken_test(reason="temporary disabled") def test_remote_link(self): - link = self.jira.remote_link("QA-44", "10000") - self.assertEqual(link.id, 10000) + added_link = self.jira.add_remote_link( + self.issue_1, + destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, + globalId="python-test:story.of.horse.riding", + application={"name": "far too silly", "type": "sketch"}, + relationship="mousebending", + ) + link = self.jira.remote_link(self.issue_1, added_link.id) + self.assertEqual(link.id, added_link.id) self.assertTrue(hasattr(link, "globalId")) self.assertTrue(hasattr(link, "relationship")) + self.assertTrue(hasattr(link, "application")) + self.assertTrue(hasattr(link, "object")) + + link.delete() - @broken_test(reason="temporary disabled") def test_remote_link_with_issue_obj(self): - issue = self.jira.issue("QA-44") - link = self.jira.remote_link(issue, "10000") - self.assertEqual(link.id, 10000) + added_link = self.jira.add_remote_link( + self.issue_1, + destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, + globalId="python-test:story.of.horse.riding", + application={"name": "far too silly", "type": "sketch"}, + relationship="mousebending", + ) + link = self.jira.remote_link(self.project_b_issue1_obj, added_link.id) + self.assertEqual(link.id, added_link.id) self.assertTrue(hasattr(link, "globalId")) self.assertTrue(hasattr(link, "relationship")) + self.assertTrue(hasattr(link, "application")) + self.assertTrue(hasattr(link, "object")) + + link.delete() - @broken_test(reason="temporary disabled") def test_add_remote_link(self): link = self.jira.add_remote_link( - "BULK-3", + self.issue_1, + destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", - object={"url": "http://google.com", "title": "googlicious!"}, application={"name": "far too silly", "type": "sketch"}, relationship="mousebending", ) # creation response doesn't include full remote link info, # so we fetch it again using the new internal ID - link = self.jira.remote_link("BULK-3", link.id) + link = self.jira.remote_link(self.issue_1, link.id) self.assertEqual(link.application.name, "far too silly") self.assertEqual(link.application.type, "sketch") - self.assertEqual(link.object.url, "http://google.com") - self.assertEqual(link.object.title, "googlicious!") + self.assertEqual(link.object.url, DEFAULT_NEW_REMOTE_LINK_OBJECT["url"]) + self.assertEqual(link.object.title, DEFAULT_NEW_REMOTE_LINK_OBJECT["title"]) self.assertEqual(link.relationship, "mousebending") self.assertEqual(link.globalId, "python-test:story.of.horse.riding") - @broken_test(reason="temporary disabled") + link.delete() + def test_add_remote_link_with_issue_obj(self): - issue = self.jira.issue("BULK-3") link = self.jira.add_remote_link( - issue, + self.project_b_issue1_obj, + destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", - object={"url": "http://google.com", "title": "googlicious!"}, application={"name": "far too silly", "type": "sketch"}, relationship="mousebending", ) # creation response doesn't include full remote link info, # so we fetch it again using the new internal ID - link = self.jira.remote_link(issue, link.id) + link = self.jira.remote_link(self.project_b_issue1_obj, link.id) self.assertEqual(link.application.name, "far too silly") self.assertEqual(link.application.type, "sketch") - self.assertEqual(link.object.url, "http://google.com") - self.assertEqual(link.object.title, "googlicious!") + self.assertEqual(link.object.url, DEFAULT_NEW_REMOTE_LINK_OBJECT["url"]) + self.assertEqual(link.object.title, DEFAULT_NEW_REMOTE_LINK_OBJECT["title"]) self.assertEqual(link.relationship, "mousebending") self.assertEqual(link.globalId, "python-test:story.of.horse.riding") - @broken_test(reason="temporary disabled") + link.delete() + def test_update_remote_link(self): link = self.jira.add_remote_link( - "BULK-3", + self.issue_1, + destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", - object={"url": "http://google.com", "title": "googlicious!"}, application={"name": "far too silly", "type": "sketch"}, relationship="mousebending", ) # creation response doesn't include full remote link info, # so we fetch it again using the new internal ID - link = self.jira.remote_link("BULK-3", link.id) + link = self.jira.remote_link(self.issue_1, link.id) + new_link = {"url": "http://yahoo.com", "title": "yahoo stuff"} link.update( - object={"url": "http://yahoo.com", "title": "yahoo stuff"}, + object=new_link, globalId="python-test:updated.id", relationship="cheesing", ) self.assertEqual(link.globalId, "python-test:updated.id") self.assertEqual(link.relationship, "cheesing") - self.assertEqual(link.object.url, "http://yahoo.com") - self.assertEqual(link.object.title, "yahoo stuff") + self.assertEqual(link.object.url, new_link["url"]) + self.assertEqual(link.object.title, new_link["title"]) link.delete() - @broken_test(reason="temporary disabled") - def test_delete_remove_link(self): + def test_delete_remote_link(self): link = self.jira.add_remote_link( - "BULK-3", + self.issue_1, + destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", - object={"url": "http://google.com", "title": "googlicious!"}, application={"name": "far too silly", "type": "sketch"}, relationship="mousebending", ) _id = link.id + link = self.jira.remote_link(self.issue_1, link.id) link.delete() - self.assertRaises(JIRAError, self.jira.remote_link, "BULK-3", _id) + self.assertRaises(JIRAError, self.jira.remote_link, self.issue_1, _id) diff --git a/tests/resources/test_resolution.py b/tests/resources/test_resolution.py index bb6beda49..c14d34570 100644 --- a/tests/resources/test_resolution.py +++ b/tests/resources/test_resolution.py @@ -1,13 +1,12 @@ -from tests.conftest import JiraTestCase, not_on_custom_jira_instance +from tests.conftest import JiraTestCase -@not_on_custom_jira_instance class ResolutionTests(JiraTestCase): def test_resolutions(self): resolutions = self.jira.resolutions() self.assertGreaterEqual(len(resolutions), 1) def test_resolution(self): - resolution = self.jira.resolution("2") - self.assertEqual(resolution.id, "2") - self.assertEqual(resolution.name, "Won't Fix") + resolution = self.jira.resolution("10002") + self.assertEqual(resolution.id, "10002") + self.assertEqual(resolution.name, "Duplicate") diff --git a/tests/resources/test_security_level.py b/tests/resources/test_security_level.py index b3b3090bf..d366d86ed 100644 --- a/tests/resources/test_security_level.py +++ b/tests/resources/test_security_level.py @@ -1,7 +1,9 @@ from tests.conftest import JiraTestCase, broken_test -@broken_test(reason="Skipped due to https://jira.atlassian.com/browse/JRA-59619") +@broken_test( + reason="Skipped due to standalone jira docker image has no security schema created by default" +) class SecurityLevelTests(JiraTestCase): def test_security_level(self): # This is hardcoded due to Atlassian bug: https://jira.atlassian.com/browse/JRA-59619 diff --git a/tests/resources/test_user.py b/tests/resources/test_user.py index db4e465b7..d21674b1b 100644 --- a/tests/resources/test_user.py +++ b/tests/resources/test_user.py @@ -1,11 +1,6 @@ import os -from tests.conftest import ( - TEST_ICON_PATH, - JiraTestCase, - broken_test, - not_on_custom_jira_instance, -) +from tests.conftest import TEST_ICON_PATH, JiraTestCase class UserTests(JiraTestCase): @@ -44,7 +39,6 @@ def test_search_assignable_users_for_projects_startat(self): ) self.assertGreaterEqual(len(users), 0) - @not_on_custom_jira_instance def test_search_assignable_users_for_issues_by_project(self): users = self.jira.search_assignable_users_for_issues( self.test_manager.CI_JIRA_ADMIN, project=self.project_b @@ -65,7 +59,6 @@ def test_search_assignable_users_for_issues_by_project_startat(self): ) self.assertGreaterEqual(len(users), 0) - @not_on_custom_jira_instance def test_search_assignable_users_for_issues_by_issue(self): users = self.jira.search_assignable_users_for_issues( self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue @@ -86,7 +79,6 @@ def test_search_assignable_users_for_issues_by_issue_startat(self): ) self.assertGreaterEqual(len(users), 0) - @broken_test(reason="Jira may return 500") def test_user_avatars(self): # Tests the end-to-end user avatar creation process: upload as temporary, confirm after cropping, # and selection. @@ -116,7 +108,6 @@ def test_user_avatars(self): ) # observed values between 20-24 so far self.assertGreaterEqual(len(avatars["custom"]), 1) - @broken_test(reason="broken: set avatar returns 400") def test_set_user_avatar(self): def find_selected_avatar(avatars): for avatar in avatars["system"]: @@ -128,22 +119,31 @@ def find_selected_avatar(avatars): avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) - self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatars["system"][0]) + self.jira.set_user_avatar( + self.test_manager.CI_JIRA_ADMIN, avatars["system"][0]["id"] + ) avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) - self.assertEqual(find_selected_avatar(avatars)["id"], avatars["system"][0]) + self.assertEqual( + find_selected_avatar(avatars)["id"], avatars["system"][0]["id"] + ) - self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatars["system"][1]) + self.jira.set_user_avatar( + self.test_manager.CI_JIRA_ADMIN, avatars["system"][1]["id"] + ) avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) - self.assertEqual(find_selected_avatar(avatars)["id"], avatars["system"][1]) + self.assertEqual( + find_selected_avatar(avatars)["id"], avatars["system"][1]["id"] + ) - # WRONG - @broken_test(reason="disable until I have permissions to write/modify") def test_delete_user_avatar(self): size = os.path.getsize(TEST_ICON_PATH) - filename = os.path.basename(TEST_ICON_PATH) with open(TEST_ICON_PATH, "rb") as icon: props = self.jira.create_temp_user_avatar( - self.test_manager.CI_JIRA_ADMIN, filename, size, icon.read() + self.test_manager.CI_JIRA_ADMIN, + TEST_ICON_PATH, + size, + icon.read(), + auto_confirm=True, ) self.jira.delete_user_avatar(self.test_manager.CI_JIRA_ADMIN, props["id"]) @@ -163,7 +163,6 @@ def test_search_allowed_users_for_issue_by_project(self): ) self.assertGreaterEqual(len(users), 1) - @not_on_custom_jira_instance def test_search_allowed_users_for_issue_by_issue(self): users = self.jira.search_allowed_users_for_issue("a", issueKey=self.issue) self.assertGreaterEqual(len(users), 1) diff --git a/tests/tests.py b/tests/tests.py index 122d40729..498bb781d 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -21,12 +21,7 @@ from jira import JIRA, Issue, JIRAError from jira.client import ResultList from jira.resources import cls_for_resource -from tests.conftest import ( - JiraTestCase, - broken_test, - not_on_custom_jira_instance, - rndpassword, -) +from tests.conftest import JiraTestCase, rndpassword LOGGER = logging.getLogger(__name__) @@ -152,7 +147,6 @@ def test_setting_bad_property_raises(self): self.assertRaises(JIRAError, self.jira.set_application_property, prop, "666") -@not_on_custom_jira_instance class FieldsTests(JiraTestCase): def test_fields(self): fields = self.jira.fields() @@ -174,11 +168,12 @@ def test_my_permissions_by_project(self): perms = self.jira.my_permissions(projectId=self.test_manager.project_a_id) self.assertGreaterEqual(len(perms["permissions"]), 10) - @broken_test(reason="broken") def test_my_permissions_by_issue(self): - perms = self.jira.my_permissions(issueKey="ZTRAVISDEB-7") + perms = self.jira.my_permissions(issueKey=self.issue_1) self.assertGreaterEqual(len(perms["permissions"]), 10) - perms = self.jira.my_permissions(issueId="11021") + perms = self.jira.my_permissions( + issueId=self.test_manager.project_b_issue1_obj.id + ) self.assertGreaterEqual(len(perms["permissions"]), 10) @@ -379,10 +374,10 @@ def _skip_pycontribs_instance(self): ) def _should_skip_for_pycontribs_instance(self): - return True - # return self.test_manager.CI_JIRA_ADMIN == "ci-admin" and ( - # self.test_manager.CI_JIRA_URL == "https://pycontribs.atlassian.net" - # ) + # return True + return self.test_manager.CI_JIRA_ADMIN == "ci-admin" and ( + self.test_manager.CI_JIRA_URL == "https://pycontribs.atlassian.net" + ) def test_add_and_remove_user(self): if self._should_skip_for_pycontribs_instance(): @@ -479,8 +474,6 @@ def test_remove_group(self): "Found group with name when it should have been deleted. Test Fails.", ) - @not_on_custom_jira_instance - @broken_test(reason="query may return empty list") def test_add_user_to_group(self): try: self.jira.add_user( From 55830e95153349a155da86c06b9a6529af6557cb Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 10 Jul 2021 16:16:44 +0100 Subject: [PATCH 068/103] add API reference links to README for new devs (#1080) --- README.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.rst b/README.rst index 4d8743d2a..737689966 100644 --- a/README.rst +++ b/README.rst @@ -139,6 +139,23 @@ Setup .. _pytest: https://docs.pytest.org/en/stable/usage.html#specifying-tests-selecting-tests +Jira REST API Reference Links +============================= + +When updating interactions with the Jira REST API please refer to the documentation below. We aim to support both Jira Cloud and Jira Server / Data Center. + +1. `Jira Cloud`_ / `Jira Server`_ (main REST API reference) +2. `Jira Software Cloud`_ / `Jira Software Server`_ (former names include: Jira Agile, Greenhopper) +3. `Jira Service Desk Cloud`_ / `Jira Service Desk Server`_ + +.. _`Jira Cloud`: https://developer.atlassian.com/cloud/jira/platform/rest/v2/ +.. _`Jira Server`: https://docs.atlassian.com/software/jira/docs/api/REST/latest/ +.. _`Jira Software Cloud`: https://developer.atlassian.com/cloud/jira/software/rest/ +.. _`Jira Software Server`: https://docs.atlassian.com/jira-software/REST/latest/ +.. _`Jira Service Desk Cloud`: https://docs.atlassian.com/jira-servicedesk/REST/cloud/ +.. _`Jira Service Desk Server`: https://docs.atlassian.com/jira-servicedesk/REST/server/ + + Credits ------- From d353a95728b69c28e790dd7d1adde26a91ed0473 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Jul 2021 16:17:33 +0100 Subject: [PATCH 069/103] Bump zipp from 3.4.1 to 3.5.0 (#1090) Bumps [zipp](https://github.com/jaraco/zipp) from 3.4.1 to 3.5.0. - [Release notes](https://github.com/jaraco/zipp/releases) - [Changelog](https://github.com/jaraco/zipp/blob/main/CHANGES.rst) - [Commits](https://github.com/jaraco/zipp/compare/v3.4.1...v3.5.0) --- updated-dependencies: - dependency-name: zipp dependency-type: direct:production update-type: version-update:semver-minor ... 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 a08cbd2c7..0302ffdfd 100644 --- a/constraints.txt +++ b/constraints.txt @@ -208,7 +208,7 @@ xmlrunner==1.7.7 # via jira (setup.cfg) yanc==0.3.3 # via jira (setup.cfg) -zipp==3.4.1 +zipp==3.5.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: From 6eaed12f3f80e351af9ae0d1c2906505188b2292 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Jul 2021 16:18:42 +0100 Subject: [PATCH 070/103] Bump packaging from 20.9 to 21.0 (#1091) Bumps [packaging](https://github.com/pypa/packaging) from 20.9 to 21.0. - [Release notes](https://github.com/pypa/packaging/releases) - [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pypa/packaging/compare/20.9...21.0) --- updated-dependencies: - dependency-name: packaging dependency-type: direct:production update-type: version-update:semver-major ... 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 0302ffdfd..ebe1b3564 100644 --- a/constraints.txt +++ b/constraints.txt @@ -72,7 +72,7 @@ oauthlib==3.1.1 # via # jira (setup.cfg) # requests-oauthlib -packaging==20.9 +packaging==21.0 # via # pytest # pytest-sugar From eafd64f415b11145e66fc9d256a8651e71b8b22c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Jul 2021 16:19:05 +0100 Subject: [PATCH 071/103] Bump sphinx from 4.0.2 to 4.0.3 (#1092) Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 4.0.2 to 4.0.3. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/4.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v4.0.2...v4.0.3) --- updated-dependencies: - dependency-name: sphinx 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 ebe1b3564..f908eb621 100644 --- a/constraints.txt +++ b/constraints.txt @@ -168,7 +168,7 @@ six==1.16.0 # traitlets snowballstemmer==2.1.0 # via sphinx -sphinx==4.0.2 +sphinx==4.0.3 # via # jira (setup.cfg) # sphinx-rtd-theme From 87e68994bcc980981934908f7cbff0b7ade52934 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Jul 2021 17:06:17 +0100 Subject: [PATCH 072/103] Bump urllib3 from 1.26.5 to 1.26.6 (#1082) Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.5 to 1.26.6. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/1.26.6/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.5...1.26.6) --- updated-dependencies: - dependency-name: urllib3 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 f908eb621..a6ba703e1 100644 --- a/constraints.txt +++ b/constraints.txt @@ -198,7 +198,7 @@ traitlets==4.3.3 # via ipython typing-extensions==3.10.0.0 # via importlib-metadata -urllib3==1.26.5 +urllib3==1.26.6 # via requests wcwidth==0.2.5 # via prompt-toolkit From a83cc8f447fa4f9b6ce55beca8b4aee4a669c098 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Fri, 23 Jul 2021 19:05:55 +0100 Subject: [PATCH 073/103] use requests.structures.CaseInsensitiveDict directly (#1084) * use requests.structures.CaseInsensitiveDict directly * add tests for attachment using filename --- docs/conf.py | 1 + jira/client.py | 3 ++- jira/resources.py | 3 ++- jira/utils/__init__.py | 37 ++++++++---------------------- tests/resources/test_attachment.py | 16 ++++++++++++- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 058643cf1..33839d3f8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -67,6 +67,7 @@ ("py:class", "Request"), ("py:class", "requests.models.Response"), ("py:class", "requests.sessions.Session"), + ("py:class", "requests.structures.CaseInsensitiveDict"), ("py:class", "Response"), ("py:mod", "requests-kerberos"), ("py:mod", "requests-oauthlib"), diff --git a/jira/client.py b/jira/client.py index 6f281f2ac..12a069fab 100644 --- a/jira/client.py +++ b/jira/client.py @@ -44,6 +44,7 @@ from pkg_resources import parse_version from requests import Response from requests.auth import AuthBase +from requests.structures import CaseInsensitiveDict from requests.utils import get_netrc_auth from jira import __version__ @@ -86,7 +87,7 @@ Watchers, Worklog, ) -from jira.utils import CaseInsensitiveDict, json_loads, threaded_requests +from jira.utils import json_loads, threaded_requests try: # noinspection PyUnresolvedReferences diff --git a/jira/resources.py b/jira/resources.py index faf43b3f0..3b3f58380 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -11,9 +11,10 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union, cast from requests import Response +from requests.structures import CaseInsensitiveDict from jira.resilientsession import ResilientSession -from jira.utils import CaseInsensitiveDict, json_loads, threaded_requests +from jira.utils import json_loads, threaded_requests if TYPE_CHECKING: from jira.client import JIRA diff --git a/jira/utils/__init__.py b/jira/utils/__init__.py index 86052b949..ac468d552 100644 --- a/jira/utils/__init__.py +++ b/jira/utils/__init__.py @@ -1,16 +1,20 @@ # -*- coding: utf-8 -*- """Jira utils used internally.""" import threading +import warnings from typing import Any, Optional, cast from requests import Response +from requests.structures import CaseInsensitiveDict as _CaseInsensitiveDict from jira.resilientsession import raise_on_error -class CaseInsensitiveDict(dict): +class CaseInsensitiveDict(_CaseInsensitiveDict): """A case-insensitive ``dict``-like object. + DEPRECATED: use requests.structures.CaseInsensitiveDict directly. + Implements all methods and operations of ``collections.MutableMapping`` as well as dict's ``copy``. Also provides ``lower_items``. @@ -36,32 +40,11 @@ class CaseInsensitiveDict(dict): """ - def __init__(self, *args, **kw): - super(CaseInsensitiveDict, self).__init__(*args, **kw) - - self.itemlist = {} - for key, value in super(CaseInsensitiveDict, self).copy().items(): - if key != key.lower(): - self[key.lower()] = value - self.pop(key, None) - - # self.itemlist[key.lower()] = value - - def __setitem__(self, key, value): - """Overwrite [] implementation.""" - super(CaseInsensitiveDict, self).__setitem__(key.lower(), value) - - # def __iter__(self): - # return iter(self.itemlist) - - # def keys(self): - # return self.itemlist - - # def values(self): - # return [self[key] for key in self] - - # def itervalues(self): - # return (self[key] for key in self) + def __init__(self, *args, **kwargs) -> None: + warnings.warn( + "Use requests.structures.CaseInsensitiveDict directly", DeprecationWarning + ) + super().__init__(*args, **kwargs) def threaded_requests(requests): diff --git a/tests/resources/test_attachment.py b/tests/resources/test_attachment.py index 667a39afd..a823f0053 100644 --- a/tests/resources/test_attachment.py +++ b/tests/resources/test_attachment.py @@ -15,7 +15,7 @@ def test_0_attachment_meta(self): # we have no control over server side upload limit self.assertIn("uploadLimit", meta) - def test_1_add_remove_attachment(self): + def test_1_add_remove_attachment_using_filestream(self): issue = self.jira.issue(self.issue_1) with open(TEST_ATTACH_PATH, "rb") as f: attachment = self.jira.add_attachment(issue, f, "new test attachment") @@ -27,3 +27,17 @@ def test_1_add_remove_attachment(self): ) # JIRA returns a HTTP 204 upon successful deletion self.assertEqual(attachment.delete().status_code, 204) + + def test_2_add_remove_attachment_using_filename(self): + issue = self.jira.issue(self.issue_1) + attachment = self.jira.add_attachment( + issue, TEST_ATTACH_PATH, "new test attachment" + ) + new_attachment = self.jira.attachment(attachment.id) + msg = "attachment %s of issue %s" % (new_attachment.__dict__, issue) + self.assertEqual(new_attachment.filename, "new test attachment", msg=msg) + self.assertEqual( + new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg + ) + # JIRA returns a HTTP 204 upon successful deletion + self.assertEqual(attachment.delete().status_code, 204) From 32f91ed13990f933dc7a46b3e10dd58f03e3c75b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jul 2021 19:32:12 +0100 Subject: [PATCH 074/103] Bump sphinx from 4.0.3 to 4.1.1 (#1101) Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 4.0.3 to 4.1.1. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/4.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v4.0.3...v4.1.1) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:production update-type: version-update:semver-minor ... 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 a6ba703e1..63d3feee1 100644 --- a/constraints.txt +++ b/constraints.txt @@ -168,7 +168,7 @@ six==1.16.0 # traitlets snowballstemmer==2.1.0 # via sphinx -sphinx==4.0.3 +sphinx==4.1.1 # via # jira (setup.cfg) # sphinx-rtd-theme From f26ea39a1cf443e2be7ea503911f0a4fcd668dc2 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Fri, 23 Jul 2021 19:33:22 +0100 Subject: [PATCH 075/103] rename MyAny to a more meaningful name (#1083) --- docs/conf.py | 2 +- jira/resources.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 33839d3f8..c060e94a9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,7 +57,7 @@ nitpick_ignore = [ ("py:class", "JIRA"), # in jira.resources we only import this class if type ("py:obj", "typing.ResourceType"), # only Py36 has a problem with this reference - ("py:class", "jira.resources.MyAny"), # Dummy subclass for type checking + ("py:class", "jira.resources.AnyLike"), # Dummy subclass for type checking # From other packages ("py:mod", "filemagic"), ("py:mod", "ipython"), diff --git a/jira/resources.py b/jira/resources.py index 3b3f58380..cea984e65 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -19,10 +19,10 @@ if TYPE_CHECKING: from jira.client import JIRA - MyAny = Any + AnyLike = Any else: - class MyAny(object): + class AnyLike(object): """Dummy subclass of base object class for when type checker is not running.""" pass @@ -584,7 +584,7 @@ def __init__( class Issue(Resource): """A Jira issue.""" - class _IssueFields(MyAny): + class _IssueFields(AnyLike): class _Comment(object): def __init__(self) -> None: self.comments: List[Comment] = [] From 9df7a3cefa3a10f981ec60f9fb9012985ee07f5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jul 2021 23:14:47 +0100 Subject: [PATCH 076/103] Bump sphinx from 4.1.1 to 4.1.2 (#1103) Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 4.1.1 to 4.1.2. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/4.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v4.1.1...v4.1.2) --- updated-dependencies: - dependency-name: sphinx 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 63d3feee1..39f42c795 100644 --- a/constraints.txt +++ b/constraints.txt @@ -168,7 +168,7 @@ six==1.16.0 # traitlets snowballstemmer==2.1.0 # via sphinx -sphinx==4.1.1 +sphinx==4.1.2 # via # jira (setup.cfg) # sphinx-rtd-theme From 33f1722ced1d65ef4a4226f5f4b832d59ad9a7ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jul 2021 23:15:16 +0100 Subject: [PATCH 077/103] Bump requests from 2.25.1 to 2.26.0 (#1100) Bumps [requests](https://github.com/psf/requests) from 2.25.1 to 2.26.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/master/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.25.1...v2.26.0) --- updated-dependencies: - dependency-name: requests dependency-type: direct:production update-type: version-update:semver-minor ... 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 39f42c795..fee358e09 100644 --- a/constraints.txt +++ b/constraints.txt @@ -136,7 +136,7 @@ pytz==2021.1 # via babel pyyaml==5.4.1 # via jira (setup.cfg) -requests==2.25.1 +requests==2.26.0 # via # jira (setup.cfg) # requests-futures From 2cbc4bc5ea4920520fb59bc1c6452b716cfb7802 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jul 2021 23:15:57 +0100 Subject: [PATCH 078/103] Bump tenacity from 7.0.0 to 8.0.1 (#1098) Bumps [tenacity](https://github.com/jd/tenacity) from 7.0.0 to 8.0.1. - [Release notes](https://github.com/jd/tenacity/releases) - [Commits](https://github.com/jd/tenacity/compare/7.0.0...8.0.1) --- updated-dependencies: - dependency-name: tenacity dependency-type: direct:production update-type: version-update:semver-major ... 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 fee358e09..83e4cb2a8 100644 --- a/constraints.txt +++ b/constraints.txt @@ -186,7 +186,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -tenacity==7.0.0 +tenacity==8.0.1 # via jira (setup.cfg) termcolor==1.1.0 # via pytest-sugar From 2f684329aecd1bf38f7c5f12a1fecb54fa0e363f Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Thu, 29 Jul 2021 23:21:56 +0100 Subject: [PATCH 079/103] update dependabot default labels --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5e06ad2f3..67f70cc19 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,6 @@ updates: interval: daily open-pull-requests-limit: 10 target-branch: master + default_labels: + - "dependencies" + - "skip-changelog" From 20680c576ebc94b32416a2b8066dbb2488119d71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jul 2021 23:22:07 +0100 Subject: [PATCH 080/103] Bump importlib-metadata from 4.5.0 to 4.6.1 (#1093) Bumps [importlib-metadata](https://github.com/python/importlib_metadata) from 4.5.0 to 4.6.1. - [Release notes](https://github.com/python/importlib_metadata/releases) - [Changelog](https://github.com/python/importlib_metadata/blob/main/CHANGES.rst) - [Commits](https://github.com/python/importlib_metadata/compare/v4.5.0...v4.6.1) --- updated-dependencies: - dependency-name: importlib-metadata dependency-type: direct:production update-type: version-update:semver-minor ... 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 83e4cb2a8..096cff4e0 100644 --- a/constraints.txt +++ b/constraints.txt @@ -47,7 +47,7 @@ idna==2.10 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==4.5.0 +importlib-metadata==4.6.1 # via # keyring # pluggy From 502e554826216fc379cbef758c43ea3f5f166336 Mon Sep 17 00:00:00 2001 From: nelli-acc <87140125+nelli-acc@users.noreply.github.com> Date: Tue, 10 Aug 2021 22:59:59 +0200 Subject: [PATCH 081/103] Make search_assignable_users_for_issues function GDPR compliant. (#1117) * Added query parameter to `search_assignable_users_for_issues()` function for GDPR compatibality. * Update docstring --- jira/client.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/jira/client.py b/jira/client.py index 12a069fab..d23c5d295 100644 --- a/jira/client.py +++ b/jira/client.py @@ -2891,21 +2891,24 @@ def search_assignable_users_for_projects( def search_assignable_users_for_issues( self, - username: str, + username: Optional[str] = None, project: Optional[str] = None, issueKey: Optional[str] = None, expand: Optional[Any] = None, startAt: int = 0, maxResults: int = 50, + query: Optional[str] = None, ): """Get a list of user Resources that match the search string for assigning or creating issues. + "username" query parameter is deprecated in Jira Cloud; the expected parameter now is "query", which can just be + the full email again. But the "user" parameter is kept for backwards compatibility, i.e. Jira Server/Data Center. This method is intended to find users that are eligible to create issues in a project or be assigned to an existing issue. When searching for eligible creators, specify a project. When searching for eligible assignees, specify an issue key. Args: - username (str): A string to match usernames against + username (Optional[str]): A string to match usernames against project (Optional[str]): Filter returned users by permission in this project (expected if a result will be used to create an issue) issueKey (Optional[str]): Filter returned users by this issue @@ -2914,17 +2917,27 @@ def search_assignable_users_for_issues( startAt (int): Index of the first user to return (Default: 0) maxResults (int): maximum number of users to return. If maxResults evaluates as False, it will try to get all items in batches. (Default: 50) + query (Optional[str]): Search term. It can just be the email. Returns: ResultList """ - params = {"username": username} + if username is not None: + params = {"username": username} + if query is not None: + params = {"query": query} if project is not None: params["project"] = project if issueKey is not None: params["issueKey"] = issueKey if expand is not None: params["expand"] = expand + + if not username and not query: + raise ValueError( + "Either 'username' or 'query' arguments must be specified." + ) + return self._fetch_pages( User, None, "user/assignable/search", startAt, maxResults, params ) From a8bae1fc3fe793d5b92d40809ff1de1113acd554 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Aug 2021 22:03:23 +0100 Subject: [PATCH 082/103] Bump wheel from 0.36.2 to 0.37.0 (#1118) Bumps [wheel](https://github.com/pypa/wheel) from 0.36.2 to 0.37.0. - [Release notes](https://github.com/pypa/wheel/releases) - [Changelog](https://github.com/pypa/wheel/blob/master/docs/news.rst) - [Commits](https://github.com/pypa/wheel/compare/0.36.2...0.37.0) --- updated-dependencies: - dependency-name: wheel dependency-type: direct:production update-type: version-update:semver-minor ... 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 096cff4e0..ef464678c 100644 --- a/constraints.txt +++ b/constraints.txt @@ -202,7 +202,7 @@ urllib3==1.26.6 # via requests wcwidth==0.2.5 # via prompt-toolkit -wheel==0.36.2 +wheel==0.37.0 # via jira (setup.cfg) xmlrunner==1.7.7 # via jira (setup.cfg) From fe78ddd99a6a8f738cc50b996259b27954b192c8 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 28 Aug 2021 12:07:36 +0100 Subject: [PATCH 083/103] Allow the adding of headers rather than just overwriting all headers (#1085) * Allow the adding of headers rather than just overwriting all headers Co-Authored-By: Peter Radcliffe <51170007+pradcliffe-ns@users.noreply.github.com> * add tests for unclobbered headers update asda * update tests to be less dependent on input test case * add to examples how this may be used Co-authored-by: Peter Radcliffe <51170007+pradcliffe-ns@users.noreply.github.com> --- docs/examples.rst | 15 +++++++++ jira/client.py | 8 +++++ tests/test_client.py | 76 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/docs/examples.rst b/docs/examples.rst index d49350dd2..37014e4d6 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -125,6 +125,21 @@ To pass additional options to Kerberos auth use dict ``kerberos_options``, e.g.: .. _jirashell-label: +Headers +------- + +Headers can be provided to the internally used ``requests.Session``. +If the user provides a header that the :py:class:`jira.client.JIRA` also attempts to set, the user provided header will take preference. + +Perhaps you want to use a custom User Agent:: + + from requests_toolbelt import user_agent + + jira = JIRA( + basic_auth=("email", "API token"), + options={"headers": {"User-Agent": user_agent("my_package", "0.0.1")}}, + ) + Issues ------ diff --git a/jira/client.py b/jira/client.py index d23c5d295..bd7f12820 100644 --- a/jira/client.py +++ b/jira/client.py @@ -354,6 +354,7 @@ def __init__( * verify -- Verify SSL certs. Defaults to ``True``. * client_cert -- a tuple of (cert,key) for the requests library for client side SSL * check_update -- Check whether using the newest python-jira library version. + * headers -- a dict to update the default headers the session uses for all API requests. basic_auth (Union[None, Tuple[str, str]]): A tuple of username and password to use when establishing a session via HTTP BASIC authentication. @@ -421,7 +422,14 @@ def __init__( self._options: Dict[str, Any] = copy.copy(JIRA.DEFAULT_OPTIONS) + if "headers" in options: + headers = copy.copy(options["headers"]) + del options["headers"] + else: + headers = {} + self._options.update(options) + self._options["headers"].update(headers) self._rank = None diff --git a/tests/test_client.py b/tests/test_client.py index a717d190b..ee37ab6fd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -55,6 +55,16 @@ def remove_by_slug(): return slug +@pytest.fixture() +def no_fields(monkeypatch): + """When we want to test the __init__ method of the jira.client.JIRA + we don't need any external calls to get the fields. + + We don't need the features of a MagicMock, hence we don't use it here. + """ + monkeypatch.setattr(jira.client.JIRA, "fields", lambda *args, **kwargs: []) + + def test_delete_project(cl_admin, cl_normal, slug): assert cl_admin.delete_project(slug) @@ -119,3 +129,69 @@ def test_result_list_if_empty(): with pytest.raises(StopIteration): next(results) + + +@pytest.mark.parametrize( + "options_arg", + [ + {"headers": {"Content-Type": "application/json;charset=UTF-8"}}, + {"headers": {"random-header": "nice random"}}, + ], + ids=["overwrite", "new"], +) +def test_headers_unclobbered_update(options_arg, no_fields): + + assert "headers" in options_arg, "test case options must contain headers" + + # GIVEN: the headers and the expected value + header_to_check: str = list(options_arg["headers"].keys())[0] + expected_header_value: str = options_arg["headers"][header_to_check] + + invariant_header_name: str = "X-Atlassian-Token" + invariant_header_value: str = jira.client.JIRA.DEFAULT_OPTIONS["headers"][ + invariant_header_name + ] + + # We arbitrarily chose a header to check it remains unchanged/unclobbered + # so should not be overwritten by a test case + assert ( + invariant_header_name not in options_arg["headers"] + ), f"{invariant_header_name} is checked as not being overwritten in this test" + + # WHEN: we initialise the JIRA class and get the headers + jira_client = jira.client.JIRA( + server="https://jira.atlasian.com", + get_server_info=False, + validate=False, + options=options_arg, + ) + + session_headers = jira_client._session.headers + + # THEN: we have set the right headers and not affect the other headers' defaults + assert session_headers[header_to_check] == expected_header_value + assert session_headers[invariant_header_name] == invariant_header_value + + +def test_headers_unclobbered_update_with_no_provided_headers(no_fields): + + options_arg = {} # a dict with "headers" not set + + # GIVEN:the headers and the expected value + invariant_header_name: str = "X-Atlassian-Token" + invariant_header_value: str = jira.client.JIRA.DEFAULT_OPTIONS["headers"][ + invariant_header_name + ] + + # WHEN: we initialise the JIRA class with no provided headers and get the headers + jira_client = jira.client.JIRA( + server="https://jira.atlasian.com", + get_server_info=False, + validate=False, + options=options_arg, + ) + + session_headers = jira_client._session.headers + + # THEN: we have not affected the other headers' defaults + assert session_headers[invariant_header_name] == invariant_header_value From 93643790ee861d11386263502da589d9fb9dcc1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Sep 2021 23:04:01 +0100 Subject: [PATCH 084/103] Bump typing-extensions from 3.10.0.0 to 3.10.0.2 (#1136) Bumps [typing-extensions](https://github.com/python/typing) from 3.10.0.0 to 3.10.0.2. - [Release notes](https://github.com/python/typing/releases) - [Commits](https://github.com/python/typing/compare/3.10.0.0...3.10.0.2) --- updated-dependencies: - dependency-name: typing-extensions 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 ef464678c..c80a90a96 100644 --- a/constraints.txt +++ b/constraints.txt @@ -196,7 +196,7 @@ toml==0.10.2 # pytest-cov traitlets==4.3.3 # via ipython -typing-extensions==3.10.0.0 +typing-extensions==3.10.0.2 # via importlib-metadata urllib3==1.26.6 # via requests From 70be39e6e57e645eca43354a2506f8d819a34ab9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Sep 2021 23:04:28 +0100 Subject: [PATCH 085/103] Bump keyring from 23.0.1 to 23.2.1 (#1141) Bumps [keyring](https://github.com/jaraco/keyring) from 23.0.1 to 23.2.1. - [Release notes](https://github.com/jaraco/keyring/releases) - [Changelog](https://github.com/jaraco/keyring/blob/main/CHANGES.rst) - [Commits](https://github.com/jaraco/keyring/compare/v23.0.1...v23.2.1) --- updated-dependencies: - dependency-name: keyring dependency-type: direct:production update-type: version-update:semver-minor ... 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 c80a90a96..49b92a02c 100644 --- a/constraints.txt +++ b/constraints.txt @@ -62,7 +62,7 @@ jedi==0.18.0 # via ipython jinja2==3.0.1 # via sphinx -keyring==23.0.1 +keyring==23.2.1 # via jira (setup.cfg) markupsafe==2.0.1 # via From 6fa0e73d27638fff3b1a1645d85b2632ad005b02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Sep 2021 23:05:34 +0100 Subject: [PATCH 086/103] Bump cryptography from 3.4.7 to 3.4.8 (#1126) Bumps [cryptography](https://github.com/pyca/cryptography) from 3.4.7 to 3.4.8. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/3.4.7...3.4.8) --- updated-dependencies: - dependency-name: cryptography 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 49b92a02c..e92ea569c 100644 --- a/constraints.txt +++ b/constraints.txt @@ -22,7 +22,7 @@ chardet==4.0.0 # via requests coverage==5.5 # via pytest-cov -cryptography==3.4.7 +cryptography==3.4.8 # via requests-kerberos decorator==5.0.9 # via From aedb5539cb41fd988272985427127748d4bc99c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Sep 2021 23:06:08 +0100 Subject: [PATCH 087/103] Bump pygments from 2.9.0 to 2.10.0 (#1122) Bumps [pygments](https://github.com/pygments/pygments) from 2.9.0 to 2.10.0. - [Release notes](https://github.com/pygments/pygments/releases) - [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES) - [Commits](https://github.com/pygments/pygments/compare/2.9.0...2.10.0) --- updated-dependencies: - dependency-name: pygments dependency-type: direct:production update-type: version-update:semver-minor ... 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 e92ea569c..6bcfebd73 100644 --- a/constraints.txt +++ b/constraints.txt @@ -96,7 +96,7 @@ py==1.10.0 # pytest-forked pycparser==2.20 # via cffi -pygments==2.9.0 +pygments==2.10.0 # via # ipython # sphinx From c0b587c472f6da3123e8633cbfbabe5c6563eb3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Sep 2021 23:06:44 +0100 Subject: [PATCH 088/103] Bump prompt-toolkit from 3.0.19 to 3.0.20 (#1124) Bumps [prompt-toolkit](https://github.com/prompt-toolkit/python-prompt-toolkit) from 3.0.19 to 3.0.20. - [Release notes](https://github.com/prompt-toolkit/python-prompt-toolkit/releases) - [Changelog](https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/CHANGELOG) - [Commits](https://github.com/prompt-toolkit/python-prompt-toolkit/compare/3.0.19...3.0.20) --- updated-dependencies: - dependency-name: prompt-toolkit 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 6bcfebd73..54544a21a 100644 --- a/constraints.txt +++ b/constraints.txt @@ -85,7 +85,7 @@ pickleshare==0.7.5 # via ipython pluggy==0.13.1 # via pytest -prompt-toolkit==3.0.19 +prompt-toolkit==3.0.20 # via ipython ptyprocess==0.7.0 # via pexpect From bb3ff3e11540e80829d10f20853c5bfb10d34230 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Sep 2021 23:07:49 +0100 Subject: [PATCH 089/103] Bump pytest from 6.2.4 to 6.2.5 (#1147) Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.2.4 to 6.2.5. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.2.4...6.2.5) --- updated-dependencies: - dependency-name: pytest 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 54544a21a..db56410cb 100644 --- a/constraints.txt +++ b/constraints.txt @@ -108,7 +108,7 @@ pykerberos==1.2.1 # via requests-kerberos pyparsing==2.4.7 # via packaging -pytest==6.2.4 +pytest==6.2.5 # via # jira (setup.cfg) # pytest-cache From ae6c84c76d48041a7d7e69fbddbd7a6fb7a8b188 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Sep 2021 23:25:36 +0100 Subject: [PATCH 090/103] Bump importlib-metadata from 4.6.1 to 4.8.1 (#1134) Bumps [importlib-metadata](https://github.com/python/importlib_metadata) from 4.6.1 to 4.8.1. - [Release notes](https://github.com/python/importlib_metadata/releases) - [Changelog](https://github.com/python/importlib_metadata/blob/main/CHANGES.rst) - [Commits](https://github.com/python/importlib_metadata/compare/v4.6.1...v4.8.1) --- updated-dependencies: - dependency-name: importlib-metadata dependency-type: direct:production update-type: version-update:semver-minor ... 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 db56410cb..231773116 100644 --- a/constraints.txt +++ b/constraints.txt @@ -47,7 +47,7 @@ idna==2.10 # via requests imagesize==1.2.0 # via sphinx -importlib-metadata==4.6.1 +importlib-metadata==4.8.1 # via # keyring # pluggy From 6e785a088d4fac9d2a6635473521892ccd18a667 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Sep 2021 11:37:05 +0100 Subject: [PATCH 091/103] Bump sphinx from 4.1.2 to 4.2.0 (#1150) Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 4.1.2 to 4.2.0. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/4.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v4.1.2...v4.2.0) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:production update-type: version-update:semver-minor ... 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 231773116..31a89f3d0 100644 --- a/constraints.txt +++ b/constraints.txt @@ -168,7 +168,7 @@ six==1.16.0 # traitlets snowballstemmer==2.1.0 # via sphinx -sphinx==4.1.2 +sphinx==4.2.0 # via # jira (setup.cfg) # sphinx-rtd-theme From ef92fbff23efa9fdfd35b135baa53284cebdd214 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Sep 2021 12:01:43 +0100 Subject: [PATCH 092/103] Bump cffi from 1.14.5 to 1.14.6 (#1097) Bumps [cffi](http://cffi.readthedocs.org) from 1.14.5 to 1.14.6. --- updated-dependencies: - dependency-name: cffi 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 31a89f3d0..9450e49ec 100644 --- a/constraints.txt +++ b/constraints.txt @@ -16,7 +16,7 @@ backcall==0.2.0 # via ipython certifi==2021.5.30 # via requests -cffi==1.14.5 +cffi==1.14.6 # via cryptography chardet==4.0.0 # via requests From 956a6f2e684fb2cee650d93bd55fabdc3d25ec24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Sep 2021 12:06:25 +0100 Subject: [PATCH 093/103] Bump pluggy from 0.13.1 to 1.0.0 (#1127) Bumps [pluggy](https://github.com/pytest-dev/pluggy) from 0.13.1 to 1.0.0. - [Release notes](https://github.com/pytest-dev/pluggy/releases) - [Changelog](https://github.com/pytest-dev/pluggy/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pluggy/compare/0.13.1...1.0.0) --- updated-dependencies: - dependency-name: pluggy dependency-type: direct:production update-type: version-update:semver-major ... 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 9450e49ec..0888e2682 100644 --- a/constraints.txt +++ b/constraints.txt @@ -83,7 +83,7 @@ pexpect==4.8.0 # via ipython pickleshare==0.7.5 # via ipython -pluggy==0.13.1 +pluggy==1.0.0 # via pytest prompt-toolkit==3.0.20 # via ipython From c852909ba938c501626f6de9eaca82a33c46a235 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Sep 2021 12:07:34 +0100 Subject: [PATCH 094/103] Bump decorator from 5.0.9 to 5.1.0 (#1148) Bumps [decorator](https://github.com/micheles/decorator) from 5.0.9 to 5.1.0. - [Release notes](https://github.com/micheles/decorator/releases) - [Changelog](https://github.com/micheles/decorator/blob/master/CHANGES.md) - [Commits](https://github.com/micheles/decorator/commits) --- updated-dependencies: - dependency-name: decorator dependency-type: direct:production update-type: version-update:semver-minor ... 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 0888e2682..ce3a0b4a3 100644 --- a/constraints.txt +++ b/constraints.txt @@ -24,7 +24,7 @@ coverage==5.5 # via pytest-cov cryptography==3.4.8 # via requests-kerberos -decorator==5.0.9 +decorator==5.1.0 # via # ipython # traitlets From 385cb3cab2f8fc8b15f980f23d0a394074d68775 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 18 Sep 2021 13:49:44 +0100 Subject: [PATCH 095/103] allow lint to run on non *nix --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 491f8fe39..b49ac1c61 100644 --- a/tox.ini +++ b/tox.ini @@ -114,7 +114,7 @@ deps = pre-commit>=1.17.0 commands= python -m pre_commit run --color=always {posargs:--all} setenv = - PIP_CONSTRAINT=/dev/null + PIP_CONSTRAINT= skip_install = true usedevelop = false From fa9da66851cb15d15cd99fb84f2c51173fd0ca24 Mon Sep 17 00:00:00 2001 From: Bertrand Mathieu Date: Sat, 18 Sep 2021 16:02:18 +0200 Subject: [PATCH 096/103] fix TB on RequestType instanciation with raw content (fixes #1130) (#1133) * fix TB on RequestType instanciation with raw content (fixes #1130) --- jira/resources.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jira/resources.py b/jira/resources.py index cea984e65..919da9bfb 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -1298,10 +1298,6 @@ def __init__( session: ResilientSession, raw: Dict[str, Any] = None, ): - if raw: - self._parse_raw(raw) - self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) - Resource.__init__( self, "servicedesk/{0}/requesttype", @@ -1310,6 +1306,10 @@ def __init__( "{server}/rest/servicedeskapi/{path}", ) + if raw: + self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) + # Utilities From 608788bfa74262d667dd0d1803de9ca05cdb6523 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Sep 2021 15:41:53 +0100 Subject: [PATCH 097/103] Bump sphinx-rtd-theme from 0.5.2 to 1.0.0 (#1149) Bumps [sphinx-rtd-theme](https://github.com/readthedocs/sphinx_rtd_theme) from 0.5.2 to 1.0.0. - [Release notes](https://github.com/readthedocs/sphinx_rtd_theme/releases) - [Changelog](https://github.com/readthedocs/sphinx_rtd_theme/blob/master/docs/changelog.rst) - [Commits](https://github.com/readthedocs/sphinx_rtd_theme/compare/0.5.2...1.0.0) --- updated-dependencies: - dependency-name: sphinx-rtd-theme dependency-type: direct:production update-type: version-update:semver-major ... 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 ce3a0b4a3..7b203e51a 100644 --- a/constraints.txt +++ b/constraints.txt @@ -172,7 +172,7 @@ sphinx==4.2.0 # via # jira (setup.cfg) # sphinx-rtd-theme -sphinx-rtd-theme==0.5.2 +sphinx-rtd-theme==1.0.0 # via jira (setup.cfg) sphinxcontrib-applehelp==1.0.2 # via sphinx From bf3910e2bc1fec5d84ffbabfc1e8d218feefaefc Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 18 Sep 2021 16:17:29 +0100 Subject: [PATCH 098/103] chore(github): default labels for dependabot --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 67f70cc19..981f8ad3b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,6 @@ updates: interval: daily open-pull-requests-limit: 10 target-branch: master - default_labels: + labels: - "dependencies" - "skip-changelog" From e756078a45739b4352023ddff96fb54a11f0c192 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 24 Sep 2021 10:20:10 +0200 Subject: [PATCH 099/103] [RFC] add pyupgrade as pre-commit hook and use --py36-plus (#1144) * add pyupgrade pre-commit hook * use py36plus for pyupgrade --- .pre-commit-config.yaml | 5 ++++ docs/conf.py | 15 ++++++----- examples/basic_use.py | 4 +-- examples/greenhopper.py | 2 +- examples/maintenance.py | 1 - jira/__init__.py | 1 - jira/client.py | 21 ++++++++-------- jira/config.py | 3 +-- jira/exceptions.py | 1 - jira/jirashell.py | 4 +-- jira/resilientsession.py | 5 ++-- jira/resources.py | 33 ++++++++++++------------- jira/utils/__init__.py | 1 - make_local_jira_user.py | 13 ++++------ tests/conftest.py | 10 ++++---- tests/resources/test_attachment.py | 4 +-- tests/resources/test_component.py | 2 +- tests/resources/test_project.py | 2 +- tests/resources/test_status_category.py | 2 +- tests/resources/test_user.py | 9 ++++--- tests/test_client.py | 3 +-- tests/test_exceptions.py | 4 +-- tests/test_shell.py | 1 - tests/tests.py | 6 ++--- 24 files changed, 72 insertions(+), 80 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f9c5db55..21015317a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,3 +52,8 @@ repos: additional_dependencies: - types-requests - types-pkg_resources + - repo: https://github.com/asottile/pyupgrade + rev: v2.26.0 + hooks: + - id: pyupgrade + args: [ --py36-plus ] diff --git a/docs/conf.py b/docs/conf.py index c060e94a9..a35f9ec86 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Jira Python Client documentation build configuration file, created by # sphinx-quickstart on Thu May 3 17:01:50 2012. @@ -86,8 +85,8 @@ master_doc = "index" # General information about the project. -project = u"jira-python" -copyright = u"2012, Atlassian Pty Ltd." +project = "jira-python" +copyright = "2012, Atlassian Pty Ltd." # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -226,8 +225,8 @@ ( "index", "jirapython.tex", - u"jira-python Documentation", - u"Atlassian Pty Ltd.", + "jira-python Documentation", + "Atlassian Pty Ltd.", "manual", ) ] @@ -258,7 +257,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ("index", "jirapython", u"jira-python Documentation", [u"Atlassian Pty Ltd."], 1) + ("index", "jirapython", "jira-python Documentation", ["Atlassian Pty Ltd."], 1) ] # If true, show URL addresses after external links. @@ -280,8 +279,8 @@ ( "index", "jirapython", - u"jira-python Documentation", - u"Atlassian Pty Ltd.", + "jira-python Documentation", + "Atlassian Pty Ltd.", "jirapython", "One line description of project.", "Miscellaneous", diff --git a/examples/basic_use.py b/examples/basic_use.py index b930db688..a26f98a70 100644 --- a/examples/basic_use.py +++ b/examples/basic_use.py @@ -12,7 +12,7 @@ projects = jira.projects() # Sort available project keys, then return the second, third, and fourth keys. -keys = sorted([project.key for project in projects])[2:5] +keys = sorted(project.key for project in projects)[2:5] # Get an issue. issue = jira.issue("JRA-1330") @@ -39,7 +39,7 @@ # Or modify the List of existing labels. The new label is unicode with no # spaces -issue.fields.labels.append(u"new_text") +issue.fields.labels.append("new_text") issue.update(fields={"labels": issue.fields.labels}) # Send the issue away for good. diff --git a/examples/greenhopper.py b/examples/greenhopper.py index 408c7b378..acb12830d 100644 --- a/examples/greenhopper.py +++ b/examples/greenhopper.py @@ -14,5 +14,5 @@ # Get the sprints in a specific board board_id = 441 -print("GreenHopper board: %s (%s)" % (boards[0].name, board_id)) +print(f"GreenHopper board: {boards[0].name} ({board_id})") sprints = gh.sprints(board_id) diff --git a/examples/maintenance.py b/examples/maintenance.py index cbeb66e0d..ebef3a012 100755 --- a/examples/maintenance.py +++ b/examples/maintenance.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # # This script will cleanup your jira instance by removing all projects and # it is used to clean the CI/CD Jira server used for testing. diff --git a/jira/__init__.py b/jira/__init__.py index 54af82f72..c719a71f1 100644 --- a/jira/__init__.py +++ b/jira/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """The root of JIRA package namespace.""" try: import pkg_resources diff --git a/jira/client.py b/jira/client.py index bd7f12820..167a7d9d8 100644 --- a/jira/client.py +++ b/jira/client.py @@ -1,5 +1,4 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- """ This module implements a friendly (well, friendlier) interface between the raw JSON responses from Jira and the Resource/dict abstractions provided by this library. Users @@ -173,7 +172,7 @@ def __next__(self) -> Type[ResourceType]: return self.iterable[self.current - 1] -class QshGenerator(object): +class QshGenerator: def __init__(self, context_path): self.context_path = context_path @@ -251,7 +250,7 @@ def start_session(self): self._get_session(self.__auth) -class JIRA(object): +class JIRA: """User interface to Jira. Clients interact with Jira by constructing an instance of this object and calling its methods. For addressable @@ -399,7 +398,7 @@ def __init__( """ # force a copy of the tuple to be used in __del__() because # sys.version_info could have already been deleted in __del__() - self.sys_version_info = tuple([i for i in sys.version_info]) + self.sys_version_info = tuple(i for i in sys.version_info) if options is None: options = {} @@ -1679,7 +1678,7 @@ def assign_issue(self, issue: Union[int, str], assignee: str) -> bool: Returns: bool """ - url = self._get_latest_url("issue/{}/assignee".format(str(issue))) + url = self._get_latest_url(f"issue/{str(issue)}/assignee") user_id = self._get_user_id(assignee) payload = {"accountId": user_id} if self._is_cloud else {"name": user_id} r = self._session.put(url, data=json.dumps(payload)) @@ -1700,7 +1699,7 @@ def comments(self, issue: str, expand: Optional[str] = None) -> List[Comment]: params = {} if expand is not None: params["expand"] = expand - r_json = self._get_json("issue/{}/comment".format(str(issue)), params=params) + r_json = self._get_json(f"issue/{str(issue)}/comment", params=params) comments = [ Comment(self._options, self._session, raw_comment_json) @@ -1851,7 +1850,7 @@ def add_remote_link( data["object"] = {"title": str(destination), "url": destination.permalink()} for x in applicationlinks: if x["application"]["displayUrl"] == destination._options["server"]: - data["globalId"] = "appId=%s&issueId=%s" % ( + data["globalId"] = "appId={}&issueId={}".format( x["application"]["id"], destination.raw["id"], ) @@ -1877,7 +1876,7 @@ def add_remote_link( if isinstance(destination, Issue) and destination.raw: for x in applicationlinks: if x["application"]["displayUrl"] == self.server_url: - data["globalId"] = "appId=%s&issueId=%s" % ( + data["globalId"] = "appId={}&issueId={}".format( x["application"]["id"], destination.raw["id"], # .raw only present on Issue ) @@ -3489,7 +3488,7 @@ def _get_mime_type(self, buff: bytes) -> Optional[str]: else: try: return mimetypes.guess_type("f." + str(imghdr.what(0, buff)))[0] - except (IOError, TypeError): + except (OSError, TypeError): self.log.warning( "Couldn't detect content type of avatar image" ". Specify the 'contentType' parameter explicitly." @@ -3554,7 +3553,7 @@ def deactivate_user(self, username: str) -> Union[str, int]: user = self.session() if user.raw is None: raise JIRAError("Can not log in!") - self.authCookie = "%s=%s" % ( + self.authCookie = "{}={}".format( user.raw["session"]["name"], user.raw["session"]["value"], ) @@ -3748,7 +3747,7 @@ def backup_download(self, filename: str = None): file.write(block) except JIRAError as je: self.log.error(f"Unable to access remote backup file: {je}") - except IOError as ioe: + except OSError as ioe: self.log.error(ioe) return None diff --git a/jira/config.py b/jira/config.py index 9d54f7e88..a01fbcab2 100644 --- a/jira/config.py +++ b/jira/config.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """ This module allows people to keep their jira server credentials outside their script, in a configuration file that is not saved in the source control. @@ -108,7 +107,7 @@ def findfile(path): verify = config.getboolean(profile, "verify") else: - raise EnvironmentError( + raise OSError( "%s was not able to locate the config.ini file in current directory, user home directory or PYTHONPATH." % __name__ ) diff --git a/jira/exceptions.py b/jira/exceptions.py index 8d2d01cb4..d6d355dc2 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os import tempfile diff --git a/jira/jirashell.py b/jira/jirashell.py index 5ce5d1710..6783bfa11 100644 --- a/jira/jirashell.py +++ b/jira/jirashell.py @@ -40,7 +40,7 @@ def oauth_dance(server, consumer_key, key_cert_data, print_tokens=False, verify= if request_token is SENTINEL or request_token_secret is SENTINEL: problem = request.get("oauth_problem") if problem is not None: - message = "OAuth error: {}".format(problem) + message = f"OAuth error: {problem}" else: message = " ".join(f"{key}:{value}" for key, value in request.items()) exit(message) @@ -250,7 +250,7 @@ def process_command_line(): key_cert_data = None if args.key_cert: - with open(args.key_cert, "r") as key_cert_file: + with open(args.key_cert) as key_cert_file: key_cert_data = key_cert_file.read() oauth = {} diff --git a/jira/resilientsession.py b/jira/resilientsession.py index 349c00d95..450b860d9 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import json import logging import random @@ -96,7 +95,7 @@ def __init__(self, timeout=None): self.max_retries = 3 self.max_retry_delay = 60 self.timeout = timeout - super(ResilientSession, self).__init__() + super().__init__() # Indicate our preference for JSON to avoid https://bitbucket.org/bspeakmon/jira-python/issue/46 and https://jira.atlassian.com/browse/JRA-38551 self.headers.update({"Accept": "application/json,*.*;q=0.9"}) @@ -162,7 +161,7 @@ def __verb( response = None exception = None try: - method = getattr(super(ResilientSession, self), verb.lower()) + method = getattr(super(), verb.lower()) response = method(url, timeout=self.timeout, **kwargs) if response.status_code >= 200 and response.status_code <= 299: return response diff --git a/jira/resources.py b/jira/resources.py index 919da9bfb..7ad376ee9 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This module implements the Resource classes that translate JSON from Jira REST resources into usable objects. @@ -22,7 +21,7 @@ AnyLike = Any else: - class AnyLike(object): + class AnyLike: """Dummy subclass of base object class for when type checker is not running.""" pass @@ -91,7 +90,7 @@ def get_error_list(r: Response) -> List[str]: return error_list -class Resource(object): +class Resource: """Models a URL-addressable resource in the Jira REST API. All Resource objects provide the following: @@ -533,7 +532,7 @@ def delete(self, moveIssuesTo: Optional[str] = None): # type: ignore[override] if moveIssuesTo is not None: params["moveIssuesTo"] = moveIssuesTo - super(Component, self).delete(params) + super().delete(params) class CustomFieldOption(Resource): @@ -585,11 +584,11 @@ class Issue(Resource): """A Jira issue.""" class _IssueFields(AnyLike): - class _Comment(object): + class _Comment: def __init__(self) -> None: self.comments: List[Comment] = [] - class _Worklog(object): + class _Worklog: def __init__(self) -> None: self.worklogs: List[Worklog] = [] @@ -691,7 +690,7 @@ def update( # type: ignore[override] # incompatible supertype ignored else: fields_dict[field] = value - super(Issue, self).update(async_=async_, jira=jira, notify=notify, fields=data) + super().update(async_=async_, jira=jira, notify=notify, fields=data) def add_field_value(self, field: str, value: str): """Add a value to a field that supports multiple values, without resetting the existing values. @@ -703,7 +702,7 @@ def add_field_value(self, field: str, value: str): value (str): The field's value """ - super(Issue, self).update(fields={"update": {field: [{"add": value}]}}) + super().update(fields={"update": {field: [{"add": value}]}}) def delete(self, deleteSubtasks=False): """Delete this issue from the server. @@ -712,7 +711,7 @@ def delete(self, deleteSubtasks=False): deleteSubtasks (bool): if the issue has subtasks, this argument must be set to true for the call to succeed. """ - super(Issue, self).delete(params={"deleteSubtasks": deleteSubtasks}) + super().delete(params={"deleteSubtasks": deleteSubtasks}) def permalink(self): """Get the URL of the issue, the browsable one not the REST one. @@ -744,7 +743,7 @@ def update(self, fields=None, async_=None, jira=None, body="", visibility=None): data["body"] = body if visibility: data["visibility"] = visibility - super(Comment, self).update(data) + super().update(data) class RemoteLink(Resource): @@ -781,7 +780,7 @@ def update(self, object, globalId=None, application=None, relationship=None): if relationship is not None: data["relationship"] = relationship - super(RemoteLink, self).update(**data) + super().update(**data) class Votes(Resource): @@ -815,7 +814,7 @@ def __init__( def delete(self, username): """Remove the specified user from the watchers list.""" - super(Watchers, self).delete(params={"username": username}) + super().delete(params={"username": username}) class TimeTracking(Resource): @@ -866,7 +865,7 @@ def delete( # type: ignore[override] if increaseBy is not None: params["increaseBy"] = increaseBy - super(Worklog, self).delete(params) + super().delete(params) class IssueLink(Resource): @@ -983,7 +982,7 @@ def update( # type: ignore[override] }, } - super(Role, self).update(**data) + super().update(**data) def add_user( self, @@ -1132,7 +1131,7 @@ def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): if moveAffectedIssuesTo is not None: params["moveAffectedIssuesTo"] = moveAffectedIssuesTo - return super(Version, self).delete(params) + return super().delete(params) def update(self, **kwargs): """ @@ -1161,7 +1160,7 @@ def update(self, **kwargs): for field in kwargs: data[field] = kwargs[field] - super(Version, self).update(**data) + super().update(**data) # GreenHopper @@ -1429,6 +1428,6 @@ def cls_for_resource(resource_literal: str) -> Type[Resource]: return UnknownResource -class PropertyHolder(object): +class PropertyHolder: def __init__(self, raw): __bases__ = raw # noqa diff --git a/jira/utils/__init__.py b/jira/utils/__init__.py index ac468d552..2c0e5a266 100644 --- a/jira/utils/__init__.py +++ b/jira/utils/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Jira utils used internally.""" import threading import warnings diff --git a/make_local_jira_user.py b/make_local_jira_user.py index 5c9d8897b..45c8d8f91 100644 --- a/make_local_jira_user.py +++ b/make_local_jira_user.py @@ -22,7 +22,7 @@ def add_user_to_jira(): fullname=environ["CI_JIRA_USER_FULL_NAME"], password=environ["CI_JIRA_USER_PASSWORD"], ) - print("user {}".format(environ["CI_JIRA_USER"])) + print("user", environ["CI_JIRA_USER"]) except Exception as e: if "username already exists" not in str(e): raise e @@ -32,9 +32,8 @@ def add_user_to_jira(): start_time = time.time() timeout_mins = 15 print( - "waiting for instance of jira to be running, to add a user for CI system:\n timeout = {} mins".format( - timeout_mins - ) + "waiting for instance of jira to be running, to add a user for CI system:\n" + f" timeout = {timeout_mins} mins" ) while True: try: @@ -43,11 +42,9 @@ def add_user_to_jira(): add_user_to_jira() break except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as ex: - print( - "encountered {} while waiting for the JiraServer docker".format(str(ex)) - ) + print(f"encountered {ex} while waiting for the JiraServer docker") time.sleep(20) if start_time + 60 * timeout_mins < time.time(): raise TimeoutError( - "Jira server wasn't reachable within timeout {}".format(timeout_mins) + f"Jira server wasn't reachable within timeout {timeout_mins}" ) diff --git a/tests/conftest.py b/tests/conftest.py index 26a1d2ca8..bc81c0a9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,7 +26,7 @@ KEY_CERT_FILE = "/home/bspeakmon/src/atlassian-oauth-examples/rsa.pem" KEY_CERT_DATA = None try: - with open(KEY_CERT_FILE, "r") as cert: + with open(KEY_CERT_FILE) as cert: KEY_CERT_DATA = cert.read() OAUTH = True except Exception: @@ -113,7 +113,7 @@ def get_unique_project_name(): return "Z" + hashify(identifier) -class JiraTestManager(object): +class JiraTestManager: """Instantiate and populate the JIRA instance with data for tests. Attributes: @@ -244,17 +244,17 @@ def create_some_data(self): self.jid = get_unique_project_name() self.project_a = self.jid + "A" # old XSS - self.project_a_name = "Test user=%s key=%s A" % ( + self.project_a_name = "Test user={} key={} A".format( getpass.getuser(), self.project_a, ) self.project_b = self.jid + "B" # old BULK - self.project_b_name = "Test user=%s key=%s B" % ( + self.project_b_name = "Test user={} key={} B".format( getpass.getuser(), self.project_b, ) self.project_sd = self.jid + "C" - self.project_sd_name = "Test user=%s key=%s C" % ( + self.project_sd_name = "Test user={} key={} C".format( getpass.getuser(), self.project_sd, ) diff --git a/tests/resources/test_attachment.py b/tests/resources/test_attachment.py index a823f0053..71dbb86a3 100644 --- a/tests/resources/test_attachment.py +++ b/tests/resources/test_attachment.py @@ -20,7 +20,7 @@ def test_1_add_remove_attachment_using_filestream(self): with open(TEST_ATTACH_PATH, "rb") as f: attachment = self.jira.add_attachment(issue, f, "new test attachment") new_attachment = self.jira.attachment(attachment.id) - msg = "attachment %s of issue %s" % (new_attachment.__dict__, issue) + msg = f"attachment {new_attachment.__dict__} of issue {issue}" self.assertEqual(new_attachment.filename, "new test attachment", msg=msg) self.assertEqual( new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg @@ -34,7 +34,7 @@ def test_2_add_remove_attachment_using_filename(self): issue, TEST_ATTACH_PATH, "new test attachment" ) new_attachment = self.jira.attachment(attachment.id) - msg = "attachment %s of issue %s" % (new_attachment.__dict__, issue) + msg = f"attachment {new_attachment.__dict__} of issue {issue}" self.assertEqual(new_attachment.filename, "new test attachment", msg=msg) self.assertEqual( new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg diff --git a/tests/resources/test_component.py b/tests/resources/test_component.py index b802c52d0..95099a6ea 100644 --- a/tests/resources/test_component.py +++ b/tests/resources/test_component.py @@ -10,7 +10,7 @@ def setUp(self): def test_2_create_component(self): proj = self.jira.project(self.project_b) - name = "project-%s-component-%s" % (proj, rndstr()) + name = f"project-{proj}-component-{rndstr()}" component = self.jira.create_component( name, proj, diff --git a/tests/resources/test_project.py b/tests/resources/test_project.py index 224f47814..40feb0853 100644 --- a/tests/resources/test_project.py +++ b/tests/resources/test_project.py @@ -90,7 +90,7 @@ def test_projects_expand(self): def test_project_components(self): proj = self.jira.project(self.project_b) - name = "component-%s from project %s" % (proj, rndstr()) + name = f"component-{proj} from project {rndstr()}" component = self.jira.create_component( name, proj, diff --git a/tests/resources/test_status_category.py b/tests/resources/test_status_category.py index c204b6e13..3eaadc615 100644 --- a/tests/resources/test_status_category.py +++ b/tests/resources/test_status_category.py @@ -6,7 +6,7 @@ def test_statuscategories(self): found = False statuscategories = self.jira.statuscategories() for statuscategory in statuscategories: - if statuscategory.id == 1 and statuscategory.name == u"No Category": + if statuscategory.id == 1 and statuscategory.name == "No Category": found = True break self.assertTrue( diff --git a/tests/resources/test_user.py b/tests/resources/test_user.py index d21674b1b..354f54052 100644 --- a/tests/resources/test_user.py +++ b/tests/resources/test_user.py @@ -17,7 +17,8 @@ def test_user(self): def test_search_assignable_users_for_projects(self): users = self.jira.search_assignable_users_for_projects( - self.test_manager.CI_JIRA_ADMIN, "%s,%s" % (self.project_a, self.project_b) + self.test_manager.CI_JIRA_ADMIN, + f"{self.project_a},{self.project_b}", ) self.assertGreaterEqual(len(users), 1) usernames = map(lambda user: user.name, users) @@ -26,7 +27,7 @@ def test_search_assignable_users_for_projects(self): def test_search_assignable_users_for_projects_maxresults(self): users = self.jira.search_assignable_users_for_projects( self.test_manager.CI_JIRA_ADMIN, - "%s,%s" % (self.project_a, self.project_b), + f"{self.project_a},{self.project_b}", maxResults=1, ) self.assertLessEqual(len(users), 1) @@ -34,7 +35,7 @@ def test_search_assignable_users_for_projects_maxresults(self): def test_search_assignable_users_for_projects_startat(self): users = self.jira.search_assignable_users_for_projects( self.test_manager.CI_JIRA_ADMIN, - "%s,%s" % (self.project_a, self.project_b), + f"{self.project_a},{self.project_b}", startAt=1, ) self.assertGreaterEqual(len(users), 0) @@ -180,5 +181,5 @@ def test_search_allowed_users_for_issue_startat(self): self.assertGreaterEqual(len(users), 0) def test_add_users_to_set(self): - users_set = set([self.test_manager.user_admin, self.test_manager.user_admin]) + users_set = {self.test_manager.user_admin, self.test_manager.user_admin} self.assertEqual(len(users_set), 1) diff --git a/tests/test_client.py b/tests/test_client.py index ee37ab6fd..3b8909e15 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import getpass import pytest @@ -42,7 +41,7 @@ def remove_by_slug(): slug = get_unique_project_name() - project_name = "Test user=%s key=%s A" % (getpass.getuser(), slug) + project_name = f"Test user={getpass.getuser()} key={slug} A" try: proj = cl_admin.project(slug) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 8d0abef9b..d531c24f5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -16,7 +16,7 @@ class ExceptionsTests(unittest.TestCase): - class MockResponse(Response, object): + class MockResponse(Response): def __init__( self, headers: dict = None, @@ -38,7 +38,7 @@ def text(self): def text(self, new_text): self._text = new_text - class MalformedMockResponse(object): + class MalformedMockResponse: def __init__( self, headers: dict = None, diff --git a/tests/test_shell.py b/tests/test_shell.py index b902c2c7c..aec6490ff 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import io import sys from unittest.mock import MagicMock, patch diff --git a/tests/tests.py b/tests/tests.py index 498bb781d..20ef9a5bf 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -119,7 +119,7 @@ def test_application_properties(self): for p in props: self.assertIsInstance(p, dict) self.assertTrue( - set(p.keys()).issuperset(set(["type", "name", "value", "key", "id"])) + set(p.keys()).issuperset({"type", "name", "value", "key", "id"}) ) def test_application_property(self): @@ -325,8 +325,8 @@ def test_fetch_pages(self): items = self.jira._fetch_pages(Issue, "issues", "search", 0, False, params) self.assertEqual(len(items), total) self.assertEqual( - set(item.key for item in items), - set(expected_r["key"] for expected_r in expected_results), + {item.key for item in items}, + {expected_r["key"] for expected_r in expected_results}, ) From b634c9d45c0c78959c8555f9941a072256adef8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Sep 2021 09:20:30 +0100 Subject: [PATCH 100/103] Bump urllib3 from 1.26.6 to 1.26.7 (#1154) Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.6 to 1.26.7. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/1.26.7/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/1.26.6...1.26.7) --- updated-dependencies: - dependency-name: urllib3 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 7b203e51a..e27f62532 100644 --- a/constraints.txt +++ b/constraints.txt @@ -198,7 +198,7 @@ traitlets==4.3.3 # via ipython typing-extensions==3.10.0.2 # via importlib-metadata -urllib3==1.26.6 +urllib3==1.26.7 # via requests wcwidth==0.2.5 # via prompt-toolkit From f687dab31943bd0b4bd5bd1edea49e1ca29c9e7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Sep 2021 09:21:03 +0100 Subject: [PATCH 101/103] Bump pytest-xdist from 2.3.0 to 2.4.0 (#1153) Bumps [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) from 2.3.0 to 2.4.0. - [Release notes](https://github.com/pytest-dev/pytest-xdist/releases) - [Changelog](https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-xdist/compare/v2.3.0...v2.4.0) --- updated-dependencies: - dependency-name: pytest-xdist dependency-type: direct:production update-type: version-update:semver-minor ... 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 e27f62532..d95280ccf 100644 --- a/constraints.txt +++ b/constraints.txt @@ -130,7 +130,7 @@ pytest-sugar==0.9.4 # via jira (setup.cfg) pytest-timeout==1.4.2 # via jira (setup.cfg) -pytest-xdist==2.3.0 +pytest-xdist==2.4.0 # via jira (setup.cfg) pytz==2021.1 # via babel From caa1a8c02e4150e053c4f9d5bf4841a12621d97e Mon Sep 17 00:00:00 2001 From: Robin Hughes Date: Sun, 26 Sep 2021 20:16:04 +0100 Subject: [PATCH 102/103] Fix JWT QSH generation for urls with repeated parameters (#1157) * Fix QshGenerator to combine repeated query string parameters into csv * add test for QshGenerator.generate_qsh --- jira/client.py | 23 ++++++++++++++++++----- tests/test_qsh.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 tests/test_qsh.py diff --git a/jira/client.py b/jira/client.py index 167a7d9d8..e3551f368 100644 --- a/jira/client.py +++ b/jira/client.py @@ -37,7 +37,7 @@ cast, no_type_check, ) -from urllib.parse import urlparse +from urllib.parse import parse_qs, quote, urlparse import requests from pkg_resources import parse_version @@ -177,6 +177,10 @@ def __init__(self, context_path): self.context_path = context_path def __call__(self, req): + qsh = self._generate_qsh(req) + return hashlib.sha256(qsh.encode("utf-8")).hexdigest() + + def _generate_qsh(self, req): parse_result = urlparse(req.url) path = ( @@ -184,12 +188,21 @@ def __call__(self, req): if len(self.context_path) > 1 else parse_result.path ) - # Per Atlassian docs, use %20 for whitespace when generating qsh for URL - # https://developer.atlassian.com/cloud/jira/platform/understanding-jwt/#qsh - query = "&".join(sorted(parse_result.query.split("&"))).replace("+", "%20") + + # create canonical query string according to docs at: + # https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#qsh + params = parse_qs(parse_result.query, keep_blank_values=True) + joined = { + key: ",".join(self._sort_and_quote_values(params[key])) for key in params + } + query = "&".join(f"{key}={joined[key]}" for key in sorted(joined.keys())) + qsh = f"{req.method.upper()}&{path}&{query}" + return qsh - return hashlib.sha256(qsh.encode("utf-8")).hexdigest() + def _sort_and_quote_values(self, values): + ordered_values = sorted(values) + return [quote(value, safe="~") for value in ordered_values] class JiraCookieAuth(AuthBase): diff --git a/tests/test_qsh.py b/tests/test_qsh.py new file mode 100644 index 000000000..7641f8aea --- /dev/null +++ b/tests/test_qsh.py @@ -0,0 +1,47 @@ +import pytest + +from jira.client import QshGenerator + + +class MockRequest: + def __init__(self, method, url): + self.method = method + self.url = url + + +@pytest.mark.parametrize( + "method,url,expected", + [ + ("GET", "http://example.com", "GET&&"), + # empty parameter + ("GET", "http://example.com?key=&key2=A", "GET&&key=&key2=A"), + # whitespace + ("GET", "http://example.com?key=A+B", "GET&&key=A%20B"), + # tilde + ("GET", "http://example.com?key=A~B", "GET&&key=A~B"), + # repeated parameters + ( + "GET", + "http://example.com?key2=Z&key1=X&key3=Y&key1=A", + "GET&&key1=A,X&key2=Z&key3=Y", + ), + # repeated parameters with whitespace + ( + "GET", + "http://example.com?key2=Z+A&key1=X+B&key3=Y&key1=A+B", + "GET&&key1=A%20B,X%20B&key2=Z%20A&key3=Y", + ), + ], + ids=[ + "no parameters", + "empty parameter", + "whitespace", + "tilde", + "repeated parameters", + "repeated parameters with whitespace", + ], +) +def test_qsh(method, url, expected): + gen = QshGenerator("http://example.com") + req = MockRequest(method, url) + assert gen._generate_qsh(req) == expected From f5d7dd032e719fe35f5fc377f302200f6c69afd4 Mon Sep 17 00:00:00 2001 From: Steffen223 <87648429+Steffen223@users.noreply.github.com> Date: Tue, 28 Sep 2021 15:50:54 +0200 Subject: [PATCH 103/103] add PermissionScheme Resource (#1139) * add PermissionScheme Resource * add a test for PermissionScheme --- jira/client.py | 14 ++++++++++++++ jira/resources.py | 14 ++++++++++++++ tests/resources/test_project.py | 4 ++++ 3 files changed, 32 insertions(+) diff --git a/jira/client.py b/jira/client.py index e3551f368..ba430c65b 100644 --- a/jira/client.py +++ b/jira/client.py @@ -68,6 +68,7 @@ IssueLink, IssueLinkType, IssueType, + PermissionScheme, Priority, Project, RemoteLink, @@ -2040,6 +2041,19 @@ def votes(self, issue: str) -> Votes: """ return self._find_for_resource(Votes, issue) + @translate_resource_args + def project_permissionscheme(self, project: str) -> PermissionScheme: + """Get a PermissionScheme Resource from the server. + + + Args: + project (str): ID or key of the project to get the permissionscheme for + + Returns: + PermissionScheme: The permission scheme + """ + return self._find_for_resource(PermissionScheme, project) + @translate_resource_args def add_vote(self, issue: str) -> Response: """Register a vote for the current authenticated user on an issue. diff --git a/jira/resources.py b/jira/resources.py index 7ad376ee9..da87c9cf0 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -37,6 +37,7 @@ class AnyLike: "Dashboard", "Filter", "Votes", + "PermissionScheme", "Watchers", "Worklog", "IssueLink", @@ -798,6 +799,18 @@ def __init__( self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) +class PermissionScheme(Resource): + """Permissionscheme information on an project.""" + + def __init__(self, options, session, raw=None): + Resource.__init__( + self, "project/{0}/permissionscheme?expand=user", options, session + ) + if raw: + self._parse_raw(raw) + self.raw: Dict[str, Any] = cast(Dict[str, Any], self.raw) + + class Watchers(Resource): """Watcher information on an issue.""" @@ -1391,6 +1404,7 @@ def dict2resource( r"priority/[^/]+$": Priority, r"project/[^/]+$": Project, r"project/[^/]+/role/[^/]+$": Role, + r"project/[^/]+/permissionscheme[^/]+$": PermissionScheme, r"resolution/[^/]+$": Resolution, r"securitylevel/[^/]+$": SecurityLevel, r"status/[^/]+$": Status, diff --git a/tests/resources/test_project.py b/tests/resources/test_project.py index 40feb0853..386d9a44e 100644 --- a/tests/resources/test_project.py +++ b/tests/resources/test_project.py @@ -200,3 +200,7 @@ def test_project_roles(self): role = self.jira.project_role(self.project_b, int(admin["id"])) self.assertIn(user.name, [a.name for a in role.actors]) self.assertIn(actor_admin, [a.name for a in role.actors]) + + def test_project_permissionscheme(self): + permissionscheme = self.jira.project_permissionscheme(self.project_b) + self.assertEqual(permissionscheme.name, "Default Permission Scheme")