diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9487baa6f..7f073678f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,5 @@ --- repos: - - repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - language_version: python3 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: @@ -32,12 +27,17 @@ repos: require_serial: false additional_dependencies: [] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.267" + rev: "v0.0.270" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] + - repo: https://github.com/psf/black # after ruff, as ruff output may need fixing + rev: 23.3.0 + hooks: + - id: black + language_version: python3 - repo: https://github.com/adrienverge/yamllint - rev: v1.31.0 + rev: v1.32.0 hooks: - id: yamllint files: \.(yaml|yml)$ @@ -50,8 +50,3 @@ repos: - types-pkg_resources args: [--no-strict-optional, --ignore-missing-imports, --show-error-codes] - - repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 - hooks: - - id: pyupgrade - args: [--py38-plus] diff --git a/constraints.txt b/constraints.txt index 7e5d49408..b7b421b8e 100644 --- a/constraints.txt +++ b/constraints.txt @@ -99,6 +99,8 @@ parso==0.8.3 # via jedi pickleshare==0.7.5 # via ipython +pillow==9.5.0 + # via jira (setup.cfg) pluggy==1.0.0 # via pytest prompt-toolkit==3.0.38 @@ -116,7 +118,7 @@ pyjwt==2.6.0 # via # jira (setup.cfg) # requests-jwt -pyspnego==0.8.0 +pyspnego==0.9.1 # via requests-kerberos pytest==7.2.1 # via @@ -129,7 +131,7 @@ pytest==7.2.1 # pytest-xdist pytest-cache==1.0 # via jira (setup.cfg) -pytest-cov==4.0.0 +pytest-cov==4.1.0 # via jira (setup.cfg) pytest-instafail==0.4.2 # via jira (setup.cfg) diff --git a/docs/examples.rst b/docs/examples.rst index 2900fb3d0..d4e6e1cfa 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -31,7 +31,7 @@ default address for a Jira instance started from the Atlassian Plugin SDK. You can manually set the Jira server to use:: - jac = JIRA('https://jira.atlassian.com') + jira = JIRA('https://jira.atlassian.com') Authentication -------------- @@ -264,6 +264,29 @@ Updating components:: existingComponents.append({"name" : component.name}) issue.update(fields={"components": existingComponents}) +Working with Rich Text +^^^^^^^^^^^^^^^^^^^^^^ + +You can use rich text in an issue's description or comment. In order to use rich text, the body +content needs to be formatted using the Atlassian Document Format (ADF):: + + jira = JIRA(basic_auth=("email", "API token")) + comment = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "codeBlock", + "content": [ + { + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.", + "type": "text" + } + ] + } + ] + } + jira.add_comment("AB-123", comment) Fields ------ diff --git a/jira/client.py b/jira/client.py index b9662270b..7cc71033a 100644 --- a/jira/client.py +++ b/jira/client.py @@ -11,7 +11,6 @@ import copy import datetime import hashlib -import imghdr import json import logging as _logging import mimetypes @@ -42,6 +41,7 @@ import requests from packaging.version import parse as parse_version +from PIL import Image from requests import Response from requests.auth import AuthBase from requests.structures import CaseInsensitiveDict @@ -441,6 +441,7 @@ def __init__( * 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) + * signature_method (Optional) -- The signature method to use with OAuth. Defaults to oauthlib.oauth1.SIGNATURE_HMAC_SHA1 kerberos (bool): True to enable Kerberos authentication. (Default: ``False``) kerberos_options (Optional[Dict[str,str]]): A dict of properties for Kerberos authentication. @@ -2341,7 +2342,9 @@ def add_watcher(self, issue: str | int, watcher: str) -> Response: Response """ url = self._get_url("issue/" + str(issue) + "/watchers") - return self._session.post(url, data=json.dumps(watcher)) + # Use user_id when adding watcher + watcher_id = self._get_user_id(watcher) + return self._session.post(url, data=json.dumps(watcher_id)) @translate_resource_args def remove_watcher(self, issue: str | int, watcher: str) -> Response: @@ -3688,17 +3691,38 @@ def _create_http_basic_session(self, username: str, password: str): self._session.auth = (username, password) def _create_oauth_session(self, oauth: dict[str, Any]): - from oauthlib.oauth1 import SIGNATURE_HMAC_SHA1 + from oauthlib.oauth1 import SIGNATURE_HMAC_SHA1 as DEFAULT_SHA from requests_oauthlib import OAuth1 - oauth_instance = OAuth1( - oauth["consumer_key"], - rsa_key=oauth["key_cert"], - signature_method=SIGNATURE_HMAC_SHA1, - resource_owner_key=oauth["access_token"], - resource_owner_secret=oauth["access_token_secret"], - ) - self._session.auth = oauth_instance + try: + from oauthlib.oauth1 import SIGNATURE_RSA as FALLBACK_SHA + except ImportError: + FALLBACK_SHA = DEFAULT_SHA + _logging.debug("Fallback SHA 'SIGNATURE_RSA_SHA1' could not be imported.") + + for sha_type in (oauth.get("signature_method"), DEFAULT_SHA, FALLBACK_SHA): + if sha_type is None: + continue + oauth_instance = OAuth1( + oauth["consumer_key"], + rsa_key=oauth["key_cert"], + signature_method=sha_type, + resource_owner_key=oauth["access_token"], + resource_owner_secret=oauth["access_token_secret"], + ) + self._session.auth = oauth_instance + try: + self.myself() + _logging.debug(f"OAuth1 succeeded with signature_method={sha_type}") + return # successful response, return with happy session + except JIRAError: + _logging.exception( + f"Failed to create OAuth session with signature_method={sha_type}.\n" + + "Attempting fallback method(s)." + + "Consider specifying the signature via oauth['signature_method']." + ) + if sha_type is FALLBACK_SHA: + raise # We have exhausted our options, bubble up exception def _create_kerberos_session( self, @@ -3897,7 +3921,7 @@ def _get_mime_type(self, buff: bytes) -> str | None: if self._magic is not None: return self._magic.id_buffer(buff) try: - return mimetypes.guess_type("f." + str(imghdr.what(0, buff)))[0] + return mimetypes.guess_type("f." + Image.open(buff).format)[0] except (OSError, TypeError): self.log.warning( "Couldn't detect content type of avatar image" @@ -4377,12 +4401,12 @@ def create_project( assignee: str = None, ptype: str = "software", template_name: str = None, - avatarId=None, - issueSecurityScheme=None, - permissionScheme=None, - projectCategory=None, - notificationScheme=10000, - categoryId=None, + avatarId: int = None, + issueSecurityScheme: int = None, + permissionScheme: int = None, + projectCategory: int = None, + notificationScheme: int = 10000, + categoryId: int = None, url: str = "", ): """Create a project with the specified parameters. @@ -4390,10 +4414,20 @@ def create_project( 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. + assignee (Optional[str]): Key of the lead, if not specified it will use current user. + ptype (Optional[str]): Determines the type of project that should be created. Defaults to 'software'. + 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. + avatarId (Optional[int]): ID of the avatar to use for the project. + issueSecurityScheme (Optional[int]): Determines the security scheme to use. If none provided, will fetch the + scheme named 'Default' or the first scheme returned. + permissionScheme (Optional[int]): Determines the permission scheme to use. If none provided, will fetch the + scheme named 'Default Permission Scheme' or the first scheme returned. + projectCategory (Optional[int]): Determines the category the project belongs to. If none provided, + will fetch the one named 'Default' or the first category returned. + notificationScheme (Optional[int]): Determines the notification scheme to use. + categoryId (Optional[int]): Same as projectCategory. Can be used interchangeably. + url (Optional[string]): A link to information about the project, such as documentation. Returns: Union[bool,int]: Should evaluate to False if it fails otherwise it will be the new project id. @@ -4425,6 +4459,12 @@ def create_project( if issueSecurityScheme is None and ps_list: issueSecurityScheme = ps_list[0]["id"] + # If categoryId provided instead of projectCategory, attribute the categoryId value + # to the projectCategory variable + projectCategory = ( + categoryId if categoryId and not projectCategory else projectCategory + ) + if projectCategory is None: ps_list = self.projectcategories() for sec in ps_list: diff --git a/setup.cfg b/setup.cfg index b9f625fe4..f28a4d5b4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,6 +51,7 @@ zip_safe = False install_requires = defusedxml packaging + Pillow>=2.1.0 requests-oauthlib>=1.1.0 requests>=2.10.0 requests_toolbelt