diff --git a/.config/dictionary.txt b/.config/dictionary.txt new file mode 100644 index 000000000..306621c18 --- /dev/null +++ b/.config/dictionary.txt @@ -0,0 +1,2 @@ +CAs +assertIn diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..838a2518f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @pycontribs/jira +/.github/ @ssbarnea diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b3b7cfee5..44708ffd7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -50,7 +50,7 @@ body: attributes: label: Python Interpreter version description: The version(s) of Python used. - placeholder: "3.8" + placeholder: "3.9" validations: required: true - type: checkboxes diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 114b5fc80..dd5152eea 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,2 +1,2 @@ -# see https://github.com/ansible-community/devtools -_extends: ansible-community/devtools +# see https://github.com/ansible/team-devtools +_extends: ansible/team-devtools diff --git a/.github/workflows/ack.yml b/.github/workflows/ack.yml index 5880addda..958b0b647 100644 --- a/.github/workflows/ack.yml +++ b/.github/workflows/ack.yml @@ -1,4 +1,4 @@ -# See https://github.com/ansible-community/devtools/blob/main/.github/workflows/ack.yml +# See https://github.com/ansible/team-devtools/blob/main/.github/workflows/ack.yml name: ack on: pull_request_target: @@ -6,4 +6,4 @@ on: jobs: ack: - uses: ansible-community/devtools/.github/workflows/ack.yml@main + uses: ansible/team-devtools/.github/workflows/ack.yml@main diff --git a/.github/workflows/jira_ci.yml b/.github/workflows/jira_ci.yml index a793b12d7..dac7bc563 100644 --- a/.github/workflows/jira_ci.yml +++ b/.github/workflows/jira_ci.yml @@ -1,17 +1,16 @@ name: ci +# runs only after tox workflow finished successfully on: - # Trigger the workflow on push or pull request, - # but only for the main branch - push: - branches: - - main - pull_request: - branches: - - main + workflow_run: + workflows: [tox] + branches: [main] + types: + - completed jobs: server: + if: ${{ github.event.workflow_run.conclusion == 'success' }} uses: pycontribs/jira/.github/workflows/jira_server_ci.yml@main cloud: diff --git a/.github/workflows/jira_cloud_ci.yml b/.github/workflows/jira_cloud_ci.yml index 2ecc5b47f..377dd06cc 100644 --- a/.github/workflows/jira_cloud_ci.yml +++ b/.github/workflows/jira_cloud_ci.yml @@ -23,7 +23,7 @@ jobs: os: [ubuntu-latest] # We only test a single version to prevent concurrent # running of tests influencing one another - python-version: ["3.8"] + python-version: ["3.9"] steps: - uses: actions/checkout@v4 @@ -41,7 +41,7 @@ jobs: python -m pip install --upgrade tox tox-gh-actions - name: Test with tox - run: tox -e py38 -- -m allow_on_cloud + run: tox -e py39 -- -m allow_on_cloud env: CI_JIRA_TYPE: CLOUD CI_JIRA_CLOUD_ADMIN: ${{ secrets.CLOUD_ADMIN }} diff --git a/.github/workflows/jira_server_ci.yml b/.github/workflows/jira_server_ci.yml index 6b1c46644..9b5ca43b2 100644 --- a/.github/workflows/jira_server_ci.yml +++ b/.github/workflows/jira_server_ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] jira-version: [8.17.1] steps: diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index e8239f701..7fa9d2a58 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,4 +1,4 @@ -# See https://github.com/ansible-community/devtools/blob/main/.github/workflows/push.yml +# See https://github.com/ansible/team-devtools/blob/main/.github/workflows/push.yml name: push on: push: @@ -9,4 +9,4 @@ on: jobs: ack: - uses: ansible-community/devtools/.github/workflows/push.yml@main + uses: ansible/team-devtools/.github/workflows/push.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 473b73a39..2e7fb6c47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,10 +17,10 @@ jobs: TOX_PARALLEL_NO_SPINNER: 1 steps: - - name: Switch to using Python 3.8 by default + - name: Switch to using Python 3.9 by default uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install build dependencies run: python3 -m pip install --user tox diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 000000000..c89fc012f --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,197 @@ +--- +name: tox +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + FORCE_COLOR: 1 # tox, pytest + PY_COLORS: 1 + +jobs: + prepare: + name: prepare + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.generate_matrix.outputs.matrix }} + steps: + - name: Determine matrix + id: generate_matrix + uses: coactions/dynamic-matrix@v3 + with: + min_python: "3.9" + max_python: "3.12" + default_python: "3.9" + other_names: | + lint + docs + pkg + py39:tox -e py39 --notest + py310:tox -e py310 --notest + py311:tox -e py311 --notest + py312:tox -e py312 --notest + py39-macos:tox -e py312 --notest + py312-macos:tox -e py312 --notest + # ^ macos is also used to validate arm64 building + platforms: linux,macos + skip_explode: "1" + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os || 'ubuntu-24.04' }} + needs: + - prepare + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} + steps: + + - name: Install package dependencies (ubuntu) + if: ${{ contains(matrix.os, 'ubuntu') }} + run: | + sudo apt remove -y docker-compose + sudo apt-get update -y + sudo apt-get --assume-yes --no-install-recommends install -y apt-transport-https curl libkrb5-dev + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # needed by setuptools-scm + submodules: true + + - name: Set pre-commit cache + uses: actions/cache@v4 + if: ${{ contains(matrix.name, 'lint') }} + with: + path: | + ~/.cache/pre-commit + key: pre-commit-${{ matrix.name }}-${{ hashFiles('.pre-commit-config.yaml') }} + + - name: Set up Python ${{ matrix.python_version || '3.10' }} + uses: actions/setup-python@v5 + with: + cache: pip + python-version: ${{ matrix.python_version || '3.10' }} + cache-dependency-path: "*requirements*.txt" + + - name: Install tox + run: | + python3 -m pip install --upgrade pip wheel tox + + - run: ${{ matrix.command }} + + - run: ${{ matrix.command2 }} + if: ${{ matrix.command2 }} + + - run: ${{ matrix.command3 }} + if: ${{ matrix.command3 }} + + - run: ${{ matrix.command4 }} + if: ${{ matrix.command4 }} + + - run: ${{ matrix.command5 }} + if: ${{ matrix.command5 }} + + - name: Archive logs + uses: actions/upload-artifact@v4 + with: + name: logs-${{ matrix.name }}.zip + if-no-files-found: error + path: | + .tox/**/log/ + .tox/**/coverage.xml + + - name: Report failure if git reports dirty status + run: | + if [[ -n $(git status -s) ]]; then + # shellcheck disable=SC2016 + echo -n '::error file=git-status::' + printf '### Failed as git reported modified and/or untracked files\n```\n%s\n```\n' "$(git status -s)" | tee -a "$GITHUB_STEP_SUMMARY" + exit 99 + fi + # https://github.com/actions/toolkit/issues/193 + check: + if: always() + environment: check + permissions: + id-token: write + checks: read + + needs: + - build + + runs-on: ubuntu-24.04 + + steps: + # checkout needed for codecov action which needs codecov.yml file + - uses: actions/checkout@v4 + + - name: Set up Python # likely needed for coverage + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip3 install 'coverage>=7.5.1' + + - name: Merge logs into a single archive + uses: actions/upload-artifact/merge@v4 + with: + name: logs.zip + pattern: logs-*.zip + # artifacts like py312.zip and py312-macos do have overlapping files + separate-directories: true + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + name: logs.zip + path: . + + - name: Check for expected number of coverage.xml reports + run: | + JOBS_PRODUCING_COVERAGE=0 + if [ "$(find . -name coverage.xml | wc -l | bc)" -ne "${JOBS_PRODUCING_COVERAGE}" ]; then + echo "::error::Number of coverage.xml files was not the expected one (${JOBS_PRODUCING_COVERAGE}): $(find . -name coverage.xml |xargs echo)" + exit 1 + fi + + # Single uploads inside check job for codecov to allow use to retry + # it when it fails without running tests again. Fails often enough! + - name: Upload junit xml reports + # PRs from forks might not have access to the secret + if: env.CODECOV_TOKEN + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN || env.CODECOV_TOKEN }} + uses: codecov/test-results-action@v1 + with: + name: ${{ matrix.name }} + files: "*/tests/output/junit/*.xml" + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage data + uses: codecov/codecov-action@v4 + with: + name: ${{ matrix.name }} + # verbose: true # optional (default = false) + fail_ci_if_error: false + use_oidc: true # cspell:ignore oidc + files: "*/tests/output/reports/coverage.xml" + + # - name: Check codecov.io status + # if: github.event_name == 'pull_request' + # uses: coactions/codecov-status@main + + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + + - name: Delete Merged Artifacts + uses: actions/upload-artifact/merge@v4 + with: + delete-merged: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67f50e68e..3592a9cf1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace @@ -15,7 +15,7 @@ repos: - id: check-yaml files: .*\.(yaml|yml)$ - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell name: codespell @@ -25,9 +25,10 @@ repos: types: [text] args: [] require_serial: false - additional_dependencies: [] + additional_dependencies: + - tomli; python_version<'3.11' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.3.4" + rev: "v0.6.3" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -39,11 +40,8 @@ repos: - id: yamllint files: \.(yaml|yml)$ - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 + rev: v1.11.2 hooks: - id: mypy additional_dependencies: - types-requests - - types-pkg_resources - args: - [--no-strict-optional, --ignore-missing-imports, --show-error-codes] diff --git a/README.rst b/README.rst index 9c77432ff..24499a673 100644 --- a/README.rst +++ b/README.rst @@ -11,26 +11,16 @@ Jira Python Library .. image:: https://img.shields.io/github/issues/pycontribs/jira.svg :target: https://github.com/pycontribs/jira/issues -.. image:: https://img.shields.io/badge/irc-%23pycontribs-blue - :target: irc:///#pycontribs - ------------- - .. image:: https://readthedocs.org/projects/jira/badge/?version=main :target: https://jira.readthedocs.io/ .. image:: https://codecov.io/gh/pycontribs/jira/branch/main/graph/badge.svg :target: https://codecov.io/gh/pycontribs/jira -.. image:: https://img.shields.io/bountysource/team/pycontribs/activity.svg - :target: https://www.bountysource.com/teams/pycontribs/issues?tracker_ids=3650997 - This library eases the use of the Jira REST API from Python and it has been used in production for years. -As this is an open-source project that is community maintained, do not be surprised if some bugs or features are not implemented quickly enough. You are always welcomed to use BountySource_ to motivate others to help. - -.. _BountySource: https://www.bountysource.com/teams/pycontribs/issues?tracker_ids=3650997 +As this is an open-source project that is community maintained, do not be surprised if some bugs or features are not implemented quickly enough. Quickstart diff --git a/bindep.txt b/bindep.txt new file mode 100644 index 000000000..e4246ba0b --- /dev/null +++ b/bindep.txt @@ -0,0 +1,2 @@ +# gssapi pypi wheel build needs: +libkrb5-dev [platform:dpkg] diff --git a/constraints.txt b/constraints.txt index 9d59e9a7e..4940cd460 100644 --- a/constraints.txt +++ b/constraints.txt @@ -1,134 +1,135 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # -# pip-compile --extra=async --extra=cli --extra=docs --extra=opt --extra=test --output-file=constraints.txt --strip-extras setup.cfg +# pip-compile --extra=async --extra=cli --extra=docs --extra=opt --extra=test --output-file=constraints.txt --strip-extras # -alabaster==0.7.13 +alabaster==0.7.16 # via sphinx asttokens==2.4.1 # via stack-data -babel==2.14.0 +babel==2.16.0 # via sphinx -backcall==0.2.0 - # via ipython +backports-tarfile==1.2.0 + # via jaraco-context beautifulsoup4==4.12.3 # via furo -certifi==2024.2.2 +certifi==2024.8.30 # via requests -cffi==1.16.0 +cffi==1.17.0 # via cryptography charset-normalizer==3.3.2 # via requests -colorama==0.4.6 - # via - # ipython - # pytest - # sphinx -coverage==7.4.4 +coverage==7.6.1 # via pytest-cov -cryptography==42.0.5 +cryptography==43.0.0 # via # pyspnego # requests-kerberos decorator==5.1.1 - # via ipython + # via + # gssapi + # ipython defusedxml==0.7.1 - # via jira (setup.cfg) -docutils==0.20.1 + # via jira (pyproject.toml) +docutils==0.21.2 # via - # jira (setup.cfg) + # jira (pyproject.toml) # sphinx -exceptiongroup==1.2.0 - # via pytest -execnet==2.0.2 +exceptiongroup==1.2.2 + # via + # ipython + # pytest +execnet==2.1.1 # via # pytest-cache # pytest-xdist executing==2.0.1 # via stack-data filemagic==1.6 - # via jira (setup.cfg) + # via jira (pyproject.toml) flaky==3.8.1 - # via jira (setup.cfg) -furo==2024.1.29 - # via jira (setup.cfg) -idna==3.6 + # via jira (pyproject.toml) +furo==2024.8.6 + # via jira (pyproject.toml) +gssapi==1.8.3 + # via pyspnego +idna==3.8 # via requests imagesize==1.4.1 # via sphinx -importlib-metadata==7.1.0 +importlib-metadata==8.4.0 # via # keyring # sphinx -importlib-resources==6.4.0 - # via keyring iniconfig==2.0.0 # via pytest -ipython==8.12.3 - # via jira (setup.cfg) -jaraco-classes==3.3.1 +ipython==8.18.1 + # via jira (pyproject.toml) +jaraco-classes==3.4.0 # via keyring -jaraco-context==4.3.0 +jaraco-context==6.0.1 # via keyring -jaraco-functools==4.0.0 +jaraco-functools==4.0.2 # via keyring jedi==0.19.1 # via ipython -jinja2==3.1.3 +jinja2==3.1.4 # via sphinx -keyring==25.0.0 - # via jira (setup.cfg) +keyring==25.3.0 + # via jira (pyproject.toml) +krb5==0.6.0 + # via pyspnego markupsafe==2.1.5 # via # jinja2 - # jira (setup.cfg) -matplotlib-inline==0.1.6 + # jira (pyproject.toml) +matplotlib-inline==0.1.7 # via ipython -more-itertools==10.2.0 +more-itertools==10.4.0 # via # jaraco-classes # jaraco-functools oauthlib==3.2.2 # via - # jira (setup.cfg) + # jira (pyproject.toml) # requests-oauthlib -packaging==24.0 +packaging==24.1 # via - # jira (setup.cfg) + # jira (pyproject.toml) # pytest # pytest-sugar # sphinx parameterized==0.9.0 - # via jira (setup.cfg) -parso==0.8.3 + # via jira (pyproject.toml) +parso==0.8.4 # via jedi -pickleshare==0.7.5 +pexpect==4.9.0 # via ipython -pillow==10.2.0 - # via jira (setup.cfg) -pluggy==1.4.0 +pluggy==1.5.0 # via pytest -prompt-toolkit==3.0.43 +prompt-toolkit==3.0.47 # via ipython -pure-eval==0.2.2 +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.3 # via stack-data -pycparser==2.21 +pycparser==2.22 # via cffi -pygments==2.17.2 +pygments==2.18.0 # via # furo # ipython # sphinx -pyjwt==2.8.0 +pyjwt==2.9.0 # via - # jira (setup.cfg) + # jira (pyproject.toml) # requests-jwt -pyspnego==0.10.2 +pyspnego==0.11.1 # via requests-kerberos -pytest==8.1.1 +pytest==8.3.2 # via - # jira (setup.cfg) + # jira (pyproject.toml) # pytest-cache # pytest-cov # pytest-instafail @@ -136,26 +137,22 @@ pytest==8.1.1 # pytest-timeout # pytest-xdist pytest-cache==1.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) pytest-cov==5.0.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) pytest-instafail==0.5.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) pytest-sugar==1.0.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) pytest-timeout==2.3.1 - # via jira (setup.cfg) -pytest-xdist==3.5.0 - # via jira (setup.cfg) -pytz==2024.1 - # via babel -pywin32-ctypes==0.2.2 - # via keyring -pyyaml==6.0.1 - # via jira (setup.cfg) -requests==2.31.0 - # via - # jira (setup.cfg) + # via jira (pyproject.toml) +pytest-xdist==3.6.1 + # via jira (pyproject.toml) +pyyaml==6.0.2 + # via jira (pyproject.toml) +requests==2.32.3 + # via + # jira (pyproject.toml) # requests-futures # requests-jwt # requests-kerberos @@ -165,78 +162,73 @@ requests==2.31.0 # requires-io # sphinx requests-futures==1.0.1 - # via jira (setup.cfg) + # via jira (pyproject.toml) requests-jwt==0.6.0 - # via jira (setup.cfg) -requests-kerberos==0.14.0 - # via jira (setup.cfg) -requests-mock==1.11.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) +requests-kerberos==0.15.0 + # via jira (pyproject.toml) +requests-mock==1.12.1 + # via jira (pyproject.toml) requests-oauthlib==2.0.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) requests-toolbelt==1.0.0 - # via jira (setup.cfg) + # via jira (pyproject.toml) requires-io==0.2.6 - # via jira (setup.cfg) + # via jira (pyproject.toml) six==1.16.0 - # via - # asttokens - # requests-mock + # via asttokens snowballstemmer==2.2.0 # via sphinx -soupsieve==2.5 +soupsieve==2.6 # via beautifulsoup4 -sphinx==7.1.2 +sphinx==7.4.7 # via # furo - # jira (setup.cfg) + # jira (pyproject.toml) # sphinx-basic-ng # sphinx-copybutton sphinx-basic-ng==1.0.0b2 # via furo sphinx-copybutton==0.5.2 - # via jira (setup.cfg) -sphinxcontrib-applehelp==1.0.4 + # via jira (pyproject.toml) +sphinxcontrib-applehelp==2.0.0 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==2.0.0 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==2.0.0 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==2.0.0 # via sphinx -sspilib==0.1.0 - # via pyspnego stack-data==0.6.3 # via ipython -tenacity==8.2.3 - # via jira (setup.cfg) +tenacity==9.0.0 + # via jira (pyproject.toml) termcolor==2.4.0 # via pytest-sugar tomli==2.0.1 # via # coverage # pytest -traitlets==5.14.2 + # sphinx +traitlets==5.14.3 # via # ipython # matplotlib-inline -typing-extensions==4.10.0 +typing-extensions==4.12.2 # via # ipython - # jira (setup.cfg) -urllib3==2.2.1 + # jira (pyproject.toml) +urllib3==2.2.2 # via requests wcwidth==0.2.13 # via prompt-toolkit -wheel==0.43.0 - # via jira (setup.cfg) +wheel==0.44.0 + # via jira (pyproject.toml) yanc==0.3.3 - # via jira (setup.cfg) -zipp==3.18.1 - # via - # importlib-metadata - # importlib-resources + # via jira (pyproject.toml) +zipp==3.20.1 + # via importlib-metadata diff --git a/docs/api.rst b/docs/api.rst index a78c0feb1..454190fd5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -76,6 +76,10 @@ jira.resources module :undoc-members: :show-inheritance: +.. autoclass:: jira.resources.Field + :members: + :undoc-members: + :show-inheritance: jira.utils module ----------------- diff --git a/docs/conf.py b/docs/conf.py index 6ba493e84..40a859a22 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,7 +34,7 @@ ] intersphinx_mapping = { - "python": ("https://docs.python.org/3.8", None), + "python": ("https://docs.python.org/3.9", None), "requests": ("https://requests.readthedocs.io/en/latest/", None), "requests-oauthlib": ("https://requests-oauthlib.readthedocs.io/en/latest/", None), "ipython": ("https://ipython.readthedocs.io/en/stable/", None), @@ -81,8 +81,7 @@ # The encoding of source files. # source_encoding = 'utf-8-sig' -# The master toctree document. -master_doc = "index" +root_doc = "index" # General information about the project. project = py_pkg.__name__ @@ -99,7 +98,8 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -# language = None +language = "en" +locale_dirs: list[str] = [] # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/docs/contributing.rst b/docs/contributing.rst index 0f8c39c23..0d6fcf96f 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -75,10 +75,10 @@ Using tox * Run tests - ``tox`` * Run tests for one env only - - ``tox -e py38`` + - ``tox -e py`` * Specify what tests to run with pytest_ - ``tox -e py39 -- tests/resources/test_attachment.py`` - - ``tox -e py38 -- -m allow_on_cloud`` (Run only the cloud tests) + - ``tox -e py310 -- -m allow_on_cloud`` (Run only the cloud tests) * Debug tests with breakpoints by disabling the coverage plugin, with the ``--no-cov`` argument. - Example for VSCode on Windows : diff --git a/docs/installation.rst b/docs/installation.rst index 723abda01..d8df76072 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -34,7 +34,7 @@ Source packages are also available at PyPI: Dependencies ============ -Python >=3.8 is required. +Python >=3.9 is required. - :py:mod:`requests` - `python-requests `_ library handles the HTTP business. Usually, the latest version available at time of release is the minimum version required; at this writing, that version is 1.2.0, but any version >= 1.0.0 should work. - :py:mod:`requests-oauthlib` - Used to implement OAuth. The latest version as of this writing is 1.3.0. diff --git a/examples/auth.py b/examples/auth.py index cda716403..2cb53a8f5 100644 --- a/examples/auth.py +++ b/examples/auth.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections import Counter -from typing import cast from jira import JIRA from jira.client import ResultList @@ -25,9 +24,7 @@ props = jira.application_properties() # Find all issues reported by the admin -# Note: we cast() for mypy's benefit, as search_issues can also return the raw json ! -# This is if the following argument is used: `json_result=True` -issues = cast(ResultList[Issue], jira.search_issues("assignee=admin")) +issues: ResultList[Issue] = jira.search_issues("assignee=admin") # Find the top three projects containing issues reported by admin top_three = Counter([issue.fields.project.key for issue in issues]).most_common(3) diff --git a/jira/client.py b/jira/client.py index dcbf849ce..16adc72ef 100644 --- a/jira/client.py +++ b/jira/client.py @@ -18,20 +18,19 @@ import os import re import sys +import tempfile import time import urllib import warnings from collections import OrderedDict -from collections.abc import Iterable -from functools import lru_cache, wraps +from collections.abc import Iterable, Iterator +from functools import cache, wraps from io import BufferedReader from numbers import Number from typing import ( Any, Callable, Generic, - Iterator, - List, Literal, SupportsIndex, TypeVar, @@ -42,7 +41,6 @@ import requests from packaging.version import parse as parse_version -from PIL import Image from requests import Response from requests.auth import AuthBase from requests.structures import CaseInsensitiveDict @@ -76,6 +74,7 @@ IssueTypeScheme, NotificationScheme, PermissionScheme, + PinnedComment, Priority, PriorityScheme, Project, @@ -208,7 +207,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def _field_worker( - fields: dict[str, Any] = None, **fieldargs: Any + fields: dict[str, Any] | None = None, **fieldargs: Any ) -> dict[str, dict[str, Any]] | dict[str, dict[str, str]]: if fields is not None: return {"fields": fields} @@ -221,7 +220,7 @@ def _field_worker( class ResultList(list, Generic[ResourceType]): def __init__( self, - iterable: Iterable = None, + iterable: Iterable | None = None, _startAt: int = 0, _maxResults: int = 0, _total: int | None = None, @@ -464,23 +463,23 @@ class JIRA: def __init__( self, - server: str = None, - options: dict[str, str | bool | Any] = None, + server: str | None = None, + options: dict[str, str | bool | Any] | None = None, basic_auth: tuple[str, str] | None = None, token_auth: str | None = None, - oauth: dict[str, Any] = None, - jwt: dict[str, Any] = None, + oauth: dict[str, Any] | None = None, + jwt: dict[str, Any] | None = None, kerberos=False, - kerberos_options: dict[str, Any] = None, + kerberos_options: dict[str, Any] | None = None, validate=False, get_server_info: bool = True, async_: bool = False, async_workers: int = 5, logging: bool = True, max_retries: int = 3, - proxies: Any = None, + proxies: Any | None = None, timeout: None | float | tuple[float, float] | tuple[float, None] | None = None, - auth: tuple[str, str] = None, + auth: tuple[str, str] | None = None, default_batch_sizes: dict[type[Resource], int | None] | None = None, ): """Construct a Jira client instance. @@ -559,7 +558,6 @@ def __init__( """ # force a copy of the tuple to be used in __del__() because # sys.version_info could have already been deleted in __del__() - self.sys_version_info = tuple(sys.version_info) if options is None: options = {} @@ -745,7 +743,8 @@ def close(self): # because other references are also in the process to be torn down, # see warning section in https://docs.python.org/2/reference/datamodel.html#object.__del__ pass - self._session = None + # TODO: https://github.com/pycontribs/jira/issues/1881 + self._session = None # type: ignore[arg-type,assignment] def _check_for_html_error(self, content: str): # Jira has the bad habit of returning errors in pages with 200 and embedding the @@ -771,7 +770,7 @@ def _fetch_pages( request_path: str, startAt: int = 0, maxResults: int = 50, - params: dict[str, Any] = None, + params: dict[str, Any] | None = None, base: str = JIRA_BASE_URL, use_post: bool = False, ) -> ResultList[ResourceType]: @@ -991,7 +990,7 @@ def async_do(self, size: int = 10): # non-resource def application_properties( - self, key: str = None + self, key: str | None = None ) -> dict[str, str] | list[dict[str, str]]: """Return the mutable server application properties. @@ -1065,7 +1064,7 @@ def add_attachment( self, issue: str | int, attachment: str | BufferedReader, - filename: str = None, + filename: str | None = None, ) -> Attachment: """Attach an attachment to an issue and returns a Resource for it. @@ -1089,8 +1088,7 @@ def add_attachment( attachment_io = attachment if isinstance(attachment, BufferedReader) and attachment.mode != "rb": self.log.warning( - "%s was not opened in 'rb' mode, attaching file may fail." - % attachment.name + f"{attachment.name} was not opened in 'rb' mode, attaching file may fail." ) fname = filename @@ -1139,7 +1137,7 @@ def prepare( if not js or not isinstance(js, Iterable): raise JIRAError(f"Unable to parse JSON: {js}. Failed to add attachment?") jira_attachment = Attachment( - self._options, self._session, js[0] if isinstance(js, List) else js + self._options, self._session, js[0] if isinstance(js, list) else js ) if jira_attachment.size == 0: raise JIRAError( @@ -1570,10 +1568,10 @@ def favourite_filters(self) -> list[Filter]: def create_filter( self, - name: str = None, - description: str = None, - jql: str = None, - favourite: bool = None, + name: str | None = None, + description: str | None = None, + jql: str | None = None, + favourite: bool | None = None, ) -> Filter: """Create a new filter and return a filter Resource for it. @@ -1604,10 +1602,10 @@ def create_filter( def update_filter( self, filter_id, - name: str = None, - description: str = None, - jql: str = None, - favourite: bool = None, + name: str | None = None, + description: str | None = None, + jql: str | None = None, + favourite: bool | None = None, ): """Update a filter and return a filter Resource for it. @@ -1637,7 +1635,7 @@ def update_filter( # Groups - def group(self, id: str, expand: Any = None) -> Group: + def group(self, id: str, expand: Any | None = None) -> Group: """Get a group Resource from the server. Args: @@ -2013,7 +2011,10 @@ def service_desk(self, id: str) -> ServiceDesk: @no_type_check # FIXME: This function does not do what it wants to with fieldargs def create_customer_request( - self, fields: dict[str, Any] = None, prefetch: bool = True, **fieldargs + self, + fields: dict[str, Any] | None = None, + prefetch: bool = True, + **fieldargs, ) -> Issue: """Create a new customer request and return an issue Resource for it. @@ -2812,14 +2813,8 @@ def add_worklog( started (Optional[datetime.datetime]): Moment when the work is logged, if not specified will default to now user (Optional[str]): the user ID or name to use for this worklog visibility (Optional[Dict[str,Any]]): Details about any restrictions in the visibility of the worklog. - Optional when creating or updating a worklog. :: - ```js - { - "type": "group", # "group" or "role" - "value": "", - "identifier": "" # OPTIONAL - } - ``` + Example of visibility options when creating or updating a worklog. + ``{ "type": "group", "value": "", "identifier": ""}`` Returns: Worklog @@ -3265,7 +3260,7 @@ def create_temp_project_avatar( filename: str, size: int, avatar_img: bytes, - contentType: str = None, + contentType: str | None = None, auto_confirm: bool = False, ): """Register an image file as a project avatar. @@ -3280,7 +3275,7 @@ def create_temp_project_avatar( This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This dict should be saved and passed to :py:meth:`confirm_project_avatar` to finish the avatar creation process. - If you want to cut out the middleman and confirm the avatar with Jira's default cropping, + If you want to confirm the avatar with Jira's default cropping, pass the 'auto_confirm' argument with a truthy value and :py:meth:`confirm_project_avatar` will be called for you before this method returns. Args: @@ -3488,6 +3483,36 @@ def resolution(self, id: str) -> Resolution: # Search + @overload + def search_issues( + self, + jql_str: str, + startAt: int = 0, + maxResults: int = 50, + validate_query: bool = True, + fields: str | list[str] | None = "*all", + expand: str | None = None, + properties: str | None = None, + *, + json_result: Literal[False] = False, + use_post: bool = False, + ) -> ResultList[Issue]: ... + + @overload + def search_issues( + self, + jql_str: str, + startAt: int = 0, + maxResults: int = 50, + validate_query: bool = True, + fields: str | list[str] | None = "*all", + expand: str | None = None, + properties: str | None = None, + *, + json_result: Literal[True], + use_post: bool = False, + ) -> dict[str, Any]: ... + def search_issues( self, jql_str: str, @@ -3497,6 +3522,7 @@ def search_issues( fields: str | list[str] | None = "*all", expand: str | None = None, properties: str | None = None, + *, json_result: bool = False, use_post: bool = False, ) -> dict[str, Any] | ResultList[Issue]: @@ -3839,7 +3865,7 @@ def create_temp_user_avatar( filename: str, size: int, avatar_img: bytes, - contentType: Any = None, + contentType: Any | None = None, auto_confirm: bool = False, ): """Register an image file as a user avatar. @@ -3854,7 +3880,7 @@ def create_temp_user_avatar( This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This dict should be saved and passed to :py:meth:`confirm_user_avatar` to finish the avatar creation process. - If you want to cut out the middleman and confirm the avatar with Jira's default cropping, pass the ``auto_confirm`` argument with a truthy value and + If you want to confirm the avatar with Jira's default cropping, pass the ``auto_confirm`` argument with a truthy value and :py:meth:`confirm_user_avatar` will be called for you before this method returns. Args: @@ -4012,8 +4038,8 @@ def search_users( def search_allowed_users_for_issue( self, user: str, - issueKey: str = None, - projectKey: str = None, + issueKey: str | None = None, + projectKey: str | None = None, startAt: int = 0, maxResults: int = 50, ) -> ResultList: @@ -4045,9 +4071,9 @@ def create_version( self, name: str, project: str, - description: str = None, - releaseDate: Any = None, - startDate: Any = None, + description: str | None = None, + releaseDate: Any | None = None, + startDate: Any | None = None, archived: bool = False, released: bool = False, ) -> Version: @@ -4085,7 +4111,9 @@ def create_version( version = Version(self._options, self._session, raw=json_loads(r)) return version - def move_version(self, id: str, after: str = None, position: str = None) -> Version: + def move_version( + self, id: str, after: str | None = None, position: str | None = None + ) -> Version: """Move a version within a project's ordered version list and return a new version Resource for it. One, but not both, of ``after`` and ``position`` must be specified. @@ -4110,7 +4138,7 @@ def move_version(self, id: str, after: str = None, position: str = None) -> Vers version = Version(self._options, self._session, raw=json_loads(r)) return version - def version(self, id: str, expand: Any = None) -> Version: + def version(self, id: str, expand: Any | None = None) -> Version: """Get a version Resource. Args: @@ -4234,7 +4262,7 @@ def _create_oauth_session(self, oauth: dict[str, Any]): def _create_kerberos_session( self, - kerberos_options: dict[str, Any] = None, + kerberos_options: dict[str, Any] | None = None, ): if kerberos_options is None: kerberos_options = {} @@ -4247,8 +4275,9 @@ def _create_kerberos_session( mutual_authentication = DISABLED else: raise ValueError( - "Unknown value for mutual_authentication: %s" - % kerberos_options["mutual_authentication"] + "Unknown value for mutual_authentication: {}".format( + kerberos_options["mutual_authentication"] + ) ) self._session.auth = HTTPKerberosAuth( @@ -4260,7 +4289,7 @@ def _add_client_cert_to_session(self): If configured through the constructor. - https://docs.python-requests.org/en/master/user/advanced/#client-side-certificates + https://docs.python-requests.org/en/latest/user/advanced/#client-side-certificates - str: a single file (containing the private key and the certificate) - Tuple[str,str] a tuple of both files’ paths """ @@ -4272,7 +4301,7 @@ def _add_ssl_cert_verif_strategy_to_session(self): If configured through the constructor. - https://docs.python-requests.org/en/master/user/advanced/#ssl-cert-verification + https://docs.python-requests.org/en/latest/user/advanced/#ssl-cert-verification - str: Path to a `CA_BUNDLE` file or directory with certificates of trusted CAs. - bool: True/False """ @@ -4280,7 +4309,7 @@ def _add_ssl_cert_verif_strategy_to_session(self): self._session.verify = ssl_cert @staticmethod - def _timestamp(dt: datetime.timedelta = None): + def _timestamp(dt: datetime.timedelta | None = None): t = datetime.datetime.utcnow() if dt is not None: t += dt @@ -4367,7 +4396,7 @@ def _get_latest_url(self, path: str, base: str = JIRA_BASE_URL) -> str: def _get_json( self, path: str, - params: dict[str, Any] = None, + params: dict[str, Any] | None = None, base: str = JIRA_BASE_URL, use_post: bool = False, ): @@ -4456,7 +4485,10 @@ def _get_mime_type(self, buff: bytes) -> str | None: if self._magic is not None: return self._magic.id_buffer(buff) try: - return mimetypes.guess_type("f." + Image.open(buff).format)[0] + with tempfile.TemporaryFile() as f: + f.write(buff) + return mimetypes.guess_type(f.name)[0] + return mimetypes.guess_type(f.name)[0] except (OSError, TypeError): self.log.warning( "Couldn't detect content type of avatar image" @@ -4688,6 +4720,8 @@ def backup_complete(self) -> bool | None: self.log.warning("This functionality is not available in Server version") return None status = self.backup_progress() + if not status: + raise RuntimeError("Failed to retrieve backup progress.") perc_search = re.search(r"\s([0-9]*)\s", status["alternativePercentage"]) perc_complete = int( perc_search.group(1) # type: ignore # ignore that re.search can return None @@ -4695,12 +4729,15 @@ def backup_complete(self) -> bool | None: file_size = int(status["size"]) return perc_complete >= 100 and file_size > 0 - def backup_download(self, filename: str = None): + def backup_download(self, filename: str | None = None): """Download backup file from WebDAV (cloud only).""" if not self._is_cloud: self.log.warning("This functionality is not available in Server version") return None - remote_file = self.backup_progress()["fileName"] + progress = self.backup_progress() + if not progress: + raise RuntimeError("Unable to retrieve backup progress.") + remote_file = progress["fileName"] local_file = filename or remote_file url = self.server_url + "/webdav/backupmanager/" + remote_file try: @@ -4799,7 +4836,7 @@ def _gain_sudo_session(self, options, destination): data=payload, ) - @lru_cache(maxsize=None) + @cache def templates(self) -> dict: url = self.server_url + "/rest/project-templates/latest/templates" @@ -4814,7 +4851,7 @@ def templates(self) -> dict: # pprint(templates.keys()) return templates - @lru_cache(maxsize=None) + @cache def permissionschemes(self): url = self._get_url("permissionscheme") @@ -4823,7 +4860,7 @@ def permissionschemes(self): return data["permissionSchemes"] - @lru_cache(maxsize=None) + @cache def issue_type_schemes(self) -> list[IssueTypeScheme]: """Get all issue type schemes defined (Admin required). @@ -4837,7 +4874,7 @@ def issue_type_schemes(self) -> list[IssueTypeScheme]: return data["schemes"] - @lru_cache(maxsize=None) + @cache def issuesecurityschemes(self): url = self._get_url("issuesecurityschemes") @@ -4846,7 +4883,7 @@ def issuesecurityschemes(self): return data["issueSecuritySchemes"] - @lru_cache(maxsize=None) + @cache def projectcategories(self): url = self._get_url("projectCategory") @@ -4855,7 +4892,7 @@ def projectcategories(self): return data - @lru_cache(maxsize=None) + @cache def avatars(self, entity="project"): url = self._get_url(f"avatar/{entity}/system") @@ -4864,7 +4901,7 @@ def avatars(self, entity="project"): return data["system"] - @lru_cache(maxsize=None) + @cache def notificationschemes(self): # TODO(ssbarnea): implement pagination support url = self._get_url("notificationscheme") @@ -4873,7 +4910,7 @@ def notificationschemes(self): data: dict[str, Any] = json_loads(r) return data["values"] - @lru_cache(maxsize=None) + @cache def screens(self): # TODO(ssbarnea): implement pagination support url = self._get_url("screens") @@ -4882,7 +4919,7 @@ def screens(self): data: dict[str, Any] = json_loads(r) return data["values"] - @lru_cache(maxsize=None) + @cache def workflowscheme(self): # TODO(ssbarnea): implement pagination support url = self._get_url("workflowschemes") @@ -4891,7 +4928,7 @@ def workflowscheme(self): data = json_loads(r) return data # ['values'] - @lru_cache(maxsize=None) + @cache def workflows(self): # TODO(ssbarnea): implement pagination support url = self._get_url("workflow") @@ -4935,16 +4972,16 @@ def get_issue_type_scheme_associations(self, id: str) -> list[Project]: def create_project( self, key: str, - name: str = None, - assignee: str = None, + name: str | None = None, + assignee: str | None = None, ptype: str = "software", - template_name: str = None, - avatarId: int = None, - issueSecurityScheme: int = None, - permissionScheme: int = None, - projectCategory: int = None, + template_name: str | None = None, + avatarId: int | None = None, + issueSecurityScheme: int | None = None, + permissionScheme: int | None = None, + projectCategory: int | None = None, notificationScheme: int = 10000, - categoryId: int = None, + categoryId: int | None = None, url: str = "", ): """Create a project with the specified parameters. @@ -4987,6 +5024,8 @@ def create_project( break if permissionScheme is None and ps_list: permissionScheme = ps_list[0]["id"] + if permissionScheme is None: + raise RuntimeError("Unable to identify valid permissionScheme") if issueSecurityScheme is None: ps_list = self.issuesecurityschemes() @@ -4996,6 +5035,8 @@ def create_project( break if issueSecurityScheme is None and ps_list: issueSecurityScheme = ps_list[0]["id"] + if issueSecurityScheme is None: + raise RuntimeError("Unable to identify valid issueSecurityScheme") # If categoryId provided instead of projectCategory, attribute the categoryId value # to the projectCategory variable @@ -5111,8 +5152,8 @@ def add_user( username: str, email: str, directoryId: int = 1, - password: str = None, - fullname: str = None, + password: str | None = None, + fullname: str | None = None, notify: bool = False, active: bool = True, ignore_existing: bool = False, @@ -5248,8 +5289,8 @@ def boards( self, startAt: int = 0, maxResults: int = 50, - type: str = None, - name: str = None, + type: str | None = None, + name: str | None = None, projectKeyOrID=None, ) -> ResultList[Board]: """Get a list of board resources. @@ -5289,7 +5330,7 @@ def sprints( extended: bool | None = None, startAt: int = 0, maxResults: int = 50, - state: str = None, + state: str | None = None, ) -> ResultList[Sprint]: """Get a list of sprint Resources. @@ -5322,7 +5363,7 @@ def sprints( ) def sprints_by_name( - self, id: str | int, extended: bool = False, state: str = None + self, id: str | int, extended: bool = False, state: str | None = None ) -> dict[str, dict[str, Any]]: """Get a dictionary of sprint Resources where the name of the sprint is the key. @@ -5456,7 +5497,7 @@ def create_board( self, name: str, filter_id: str, - project_ids: str = None, + project_ids: str | None = None, preset: str = "scrum", location_type: Literal["user", "project"] = "user", location_id: str | None = None, @@ -5560,7 +5601,10 @@ def add_issues_to_sprint(self, sprint_id: int, issue_keys: list[str]) -> Respons return self._session.post(url, data=json.dumps(payload)) def add_issues_to_epic( - self, epic_id: str, issue_keys: str | list[str], ignore_epics: bool = None + self, + epic_id: str, + issue_keys: str | list[str], + ignore_epics: bool | None = None, ) -> Response: """Add the issues in ``issue_keys`` to the ``epic_id``. @@ -5656,3 +5700,36 @@ def move_to_backlog(self, issue_keys: list[str]) -> Response: url = self._get_url("backlog/issue", base=self.AGILE_BASE_URL) payload = {"issues": issue_keys} # TODO: should be list of issues return self._session.post(url, data=json.dumps(payload)) + + @translate_resource_args + def pinned_comments(self, issue: int | str) -> list[PinnedComment]: + """Get a list of pinned comment Resources of the issue provided. + + Args: + issue (Union[int, str]): the issue ID or key to get the comments from + + Returns: + List[PinnedComment] + """ + r_json = self._get_json(f"issue/{issue}/pinned-comments", params={}) + + pinned_comments = [ + PinnedComment(self._options, self._session, raw_comment_json) + for raw_comment_json in r_json + ] + return pinned_comments + + @translate_resource_args + def pin_comment(self, issue: int | str, comment: int | str, pin: bool) -> Response: + """Pin/Unpin a comment on the issue. + + Args: + issue (Union[int, str]): the issue ID or key to get the comments from + comment (Union[int, str]): the comment ID + pin (bool): Pin (True) or Unpin (False) + + Returns: + Response + """ + url = self._get_url("issue/" + str(issue) + "/comment/" + str(comment) + "/pin") + return self._session.put(url, data=str(pin).lower()) diff --git a/jira/config.py b/jira/config.py index 8216c3119..2c6974297 100644 --- a/jira/config.py +++ b/jira/config.py @@ -118,8 +118,7 @@ def findfile(path): verify = config.get(profile, "verify") else: raise OSError( - "%s was not able to locate the config.ini file in current directory, user home directory or PYTHONPATH." - % __name__ + f"{__name__} was not able to locate the config.ini file in current directory, user home directory or PYTHONPATH." ) options = JIRA.DEFAULT_OPTIONS diff --git a/jira/exceptions.py b/jira/exceptions.py index 0047133e5..ff9a0a138 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -12,11 +12,11 @@ class JIRAError(Exception): def __init__( self, - text: str = None, - status_code: int = None, - url: str = None, - request: Response = None, - response: Response = None, + text: str | None = None, + status_code: int | None = None, + url: str | None = None, + request: Response | None = None, + response: Response | None = None, **kwargs, ): """Creates a JIRAError. diff --git a/jira/resilientsession.py b/jira/resilientsession.py index f5447f2fa..306f02b19 100644 --- a/jira/resilientsession.py +++ b/jira/resilientsession.py @@ -105,11 +105,11 @@ def parse_errors(resp: Response) -> list[str]: if "message" in resp_data: # Jira 5.1 errors parsed_errors = [resp_data["message"]] - elif "errorMessage" in resp_data: + if "errorMessage" in resp_data: # Sometimes Jira returns `errorMessage` as a message error key # for example for the "Service temporary unavailable" error parsed_errors = [resp_data["errorMessage"]] - elif "errorMessages" in resp_data: + if "errorMessages" in resp_data: # Jira 5.0.x error messages sometimes come wrapped in this array # Sometimes this is present but empty error_messages = resp_data["errorMessages"] @@ -118,7 +118,7 @@ def parse_errors(resp: Response) -> list[str]: parsed_errors = list(error_messages) else: parsed_errors = [error_messages] - elif "errors" in resp_data: + if "errors" in resp_data: resp_errors = resp_data["errors"] if len(resp_errors) > 0 and isinstance(resp_errors, dict): # Catching only 'errors' that are dict. See https://github.com/pycontribs/jira/issues/350 diff --git a/jira/resources.py b/jira/resources.py index 5922d5087..29c86e604 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -10,7 +10,7 @@ import logging import re import time -from typing import TYPE_CHECKING, Any, Dict, List, Type, cast +from typing import TYPE_CHECKING, Any, cast from requests import Response from requests.structures import CaseInsensitiveDict @@ -69,6 +69,7 @@ class AnyLike: "ServiceDesk", "RequestType", "resource_class_map", + "PinnedComment", ) logging.getLogger("jira").addHandler(logging.NullHandler()) @@ -295,7 +296,7 @@ def update( self, fields: dict[str, Any] | None = None, async_: bool | None = None, - jira: JIRA = None, + jira: JIRA | None = None, notify: bool = True, **kwargs: Any, ): @@ -335,8 +336,9 @@ def update( and "reporter" not in data["fields"] ): logging.warning( - "autofix: setting reporter to '%s' and retrying the update." - % self._options["autofix"] + "autofix: setting reporter to '{}' and retrying the update.".format( + self._options["autofix"] + ) ) data["fields"]["reporter"] = {"name": self._options["autofix"]} @@ -385,8 +387,7 @@ def update( if user and jira: logging.warning( - "Trying to add missing orphan user '%s' in order to complete the previous failed operation." - % user + f"Trying to add missing orphan user '{user}' in order to complete the previous failed operation." ) jira.add_user(user, "noreply@example.com", 10100, active=False) # if 'assignee' not in data['fields']: @@ -484,12 +485,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "attachment/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def get(self): """Return the file content as a string.""" @@ -509,12 +510,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "component/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def delete(self, moveIssuesTo: str | None = None): # type: ignore[override] """Delete this component from the server. @@ -536,12 +537,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "customFieldOption/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Dashboard(Resource): @@ -551,13 +552,13 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "dashboard/{0}", options, session) if raw: self._parse_raw(raw) self.gadgets: list[DashboardGadget] = [] - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class DashboardItemPropertyKey(Resource): @@ -567,12 +568,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "dashboard/{0}/items/{1}/properties", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class DashboardItemProperty(Resource): @@ -582,14 +583,14 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__( self, "dashboard/{0}/items/{1}/properties/{2}", options, session ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] # incompatible supertype ignored self, dashboard_id: str, item_id: str, value: dict[str, Any] @@ -642,13 +643,13 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "dashboard/{0}/gadget/{1}", options, session) if raw: self._parse_raw(raw) self.item_properties: list[DashboardItemProperty] = [] - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] # incompatible supertype ignored self, @@ -718,12 +719,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "field/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Filter(Resource): @@ -733,12 +734,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "filter/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Issue(Resource): @@ -781,7 +782,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}", options, session) @@ -790,14 +791,14 @@ def __init__( self.key: str if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] # incompatible supertype ignored self, - fields: dict[str, Any] = None, - update: dict[str, Any] = None, - async_: bool = None, - jira: JIRA = None, + fields: dict[str, Any] | None = None, + update: dict[str, Any] | None = None, + async_: bool | None = None, + jira: JIRA | None = None, notify: bool = True, **fieldargs, ): @@ -906,12 +907,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/comment/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] # The above ignore is added because we've added new parameters and order of @@ -920,7 +921,7 @@ def update( # type: ignore[override] self, fields: dict[str, Any] | None = None, async_: bool | None = None, - jira: JIRA = None, + jira: JIRA | None = None, body: str = "", visibility: dict[str, str] | None = None, is_internal: bool = False, @@ -955,6 +956,21 @@ def update( # type: ignore[override] super().update(async_=async_, jira=jira, notify=notify, fields=data) +class PinnedComment(Resource): + """Pinned comment on an issue.""" + + def __init__( + self, + options: dict[str, str], + session: ResilientSession, + raw: dict[str, Any] | None = None, + ): + Resource.__init__(self, "issue/{0}/pinned-comments", options, session) + if raw: + self._parse_raw(raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) + + class RemoteLink(Resource): """A link to a remote application from an issue.""" @@ -962,14 +978,20 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/remotelink/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) - def update(self, object, globalId=None, application=None, relationship=None): + def update( # type: ignore[override] + self, + object: dict[str, Any] | None, + globalId=None, + application=None, + relationship=None, + ): """Update a RemoteLink. 'object' is required. For definitions of the allowable fields for 'object' and the keyword arguments 'globalId', 'application' and 'relationship', @@ -989,7 +1011,8 @@ def update(self, object, globalId=None, application=None, relationship=None): if relationship is not None: data["relationship"] = relationship - super().update(**data) + # https://github.com/pycontribs/jira/issues/1881 + super().update(**data) # type: ignore[arg-type] class Votes(Resource): @@ -999,12 +1022,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/votes", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class IssueTypeScheme(Resource): @@ -1014,7 +1037,7 @@ def __init__(self, options, session, raw=None): Resource.__init__(self, "issuetypescheme", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class IssueSecurityLevelScheme(Resource): @@ -1026,7 +1049,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class NotificationScheme(Resource): @@ -1038,7 +1061,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class PermissionScheme(Resource): @@ -1050,7 +1073,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class PriorityScheme(Resource): @@ -1062,7 +1085,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class WorkflowScheme(Resource): @@ -1074,7 +1097,7 @@ def __init__(self, options, session, raw=None): ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Watchers(Resource): @@ -1084,14 +1107,14 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/watchers", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) - def delete(self, username): + def delete(self, username): # type: ignore[override] """Remove the specified user from the watchers list.""" super().delete(params={"username": username}) @@ -1101,13 +1124,13 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) self.remainingEstimate = None if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Worklog(Resource): @@ -1117,12 +1140,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def delete( # type: ignore[override] self, adjustEstimate: str | None = None, newEstimate=None, increaseBy=None @@ -1154,12 +1177,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issue/{0}/properties/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def _find_by_url( self, @@ -1178,12 +1201,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issueLink/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class IssueLinkType(Resource): @@ -1193,12 +1216,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issueLinkType/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class IssueType(Resource): @@ -1208,12 +1231,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "issuetype/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Priority(Resource): @@ -1223,12 +1246,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "priority/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Project(Resource): @@ -1238,12 +1261,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "project/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Role(Resource): @@ -1253,17 +1276,17 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "project/{0}/role/{1}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def update( # type: ignore[override] self, - users: str | list | tuple = None, - groups: str | list | tuple = None, + users: str | list | tuple | None = None, + groups: str | list | tuple | None = None, ): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. @@ -1288,8 +1311,8 @@ def update( # type: ignore[override] def add_user( self, - users: str | list | tuple = None, - groups: str | list | tuple = None, + users: str | list | tuple | None = None, + groups: str | list | tuple | None = None, ): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. @@ -1313,12 +1336,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "resolution/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class SecurityLevel(Resource): @@ -1328,12 +1351,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "securitylevel/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Status(Resource): @@ -1343,12 +1366,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "status/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class StatusCategory(Resource): @@ -1358,12 +1381,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "statuscategory/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class User(Resource): @@ -1373,7 +1396,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, *, _query_param: str = "username", ): @@ -1384,7 +1407,7 @@ def __init__( Resource.__init__(self, f"user?{_query_param}" + "={0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Group(Resource): @@ -1394,12 +1417,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "group?groupname={0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Version(Resource): @@ -1409,12 +1432,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "version/{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): """Delete this project version from the server. @@ -1433,7 +1456,8 @@ def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): return super().delete(params) - def update(self, **kwargs): + # TODO: https://github.com/pycontribs/jira/issues/1881 + def update(self, **kwargs): # type: ignore[override] """Update this project version from the server. It is prior used to archive versions. Refer to Atlassian REST API `documentation`_. @@ -1478,14 +1502,14 @@ def __init__( path: str, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): self.self = None Resource.__init__(self, path, options, session, self.AGILE_BASE_URL) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class Sprint(AgileResource): @@ -1495,7 +1519,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): AgileResource.__init__(self, "sprint/{0}", options, session, raw) @@ -1507,7 +1531,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): AgileResource.__init__(self, "board/{id}", options, session, raw) @@ -1522,14 +1546,14 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__( self, "customer", options, session, "{server}/rest/servicedeskapi/{path}" ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class ServiceDesk(Resource): @@ -1539,7 +1563,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__( self, @@ -1550,7 +1574,7 @@ def __init__( ) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) class RequestType(Resource): @@ -1560,7 +1584,7 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__( self, @@ -1572,7 +1596,7 @@ def __init__( if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) # Utilities @@ -1594,9 +1618,9 @@ def dict2resource( if isinstance(j, dict): if "self" in j: # to try and help mypy know that cls_for_resource can never be 'Resource' - resource_class = cast(Type[Resource], cls_for_resource(j["self"])) + resource_class = cast(type[Resource], cls_for_resource(j["self"])) resource = cast( - Type[Resource], + type[Resource], resource_class( # type: ignore options=options, session=session, @@ -1609,17 +1633,17 @@ def dict2resource( else: setattr(top, i, dict2resource(j, options=options, session=session)) elif isinstance(j, seqs): - j = cast(List[Dict[str, Any]], j) # help mypy + j = cast(list[dict[str, Any]], j) # help mypy seq_list: list[Any] = [] for seq_elem in j: if isinstance(seq_elem, dict): if "self" in seq_elem: # to try and help mypy know that cls_for_resource can never be 'Resource' resource_class = cast( - Type[Resource], cls_for_resource(seq_elem["self"]) + type[Resource], cls_for_resource(seq_elem["self"]) ) resource = cast( - Type[Resource], + type[Resource], resource_class( # type: ignore options=options, session=session, @@ -1651,6 +1675,7 @@ def dict2resource( r"filter/[^/]$": Filter, r"issue/[^/]+$": Issue, r"issue/[^/]+/comment/[^/]+$": Comment, + r"issue/[^/]+/pinned-comments$": PinnedComment, r"issue/[^/]+/votes$": Votes, r"issue/[^/]+/watchers$": Watchers, r"issue/[^/]+/worklog/[^/]+$": Worklog, @@ -1687,12 +1712,12 @@ def __init__( self, options: dict[str, str], session: ResilientSession, - raw: dict[str, Any] = None, + raw: dict[str, Any] | None = None, ): Resource.__init__(self, "unknown{0}", options, session) if raw: self._parse_raw(raw) - self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + self.raw: dict[str, Any] = cast(dict[str, Any], self.raw) def cls_for_resource(resource_literal: str) -> type[Resource]: diff --git a/pyproject.toml b/pyproject.toml index 133e1bf3d..241842f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "jira" authors = [{name = "Ben Speakmon", email = "ben.speakmon@gmail.com"}] maintainers = [{name = "Sorin Sbarnea", email = "sorin.sbarnea@gmail.com"}] description = "Python library for interacting with JIRA via REST APIs." -requires-python = ">=3.8" +requires-python = ">=3.9" license = {text = "BSD-2-Clause"} classifiers = [ "Development Status :: 5 - Production/Stable", @@ -15,10 +15,10 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP", ] @@ -26,7 +26,6 @@ keywords = ["api", "atlassian", "jira", "rest", "web"] dependencies = [ "defusedxml", "packaging", - "Pillow>=2.1.0", "requests-oauthlib>=1.1.0", "requests>=2.10.0", "requests_toolbelt", @@ -69,7 +68,7 @@ opt = [ ] async = ["requests-futures>=0.9.7"] test = [ - "docutils>=0.12", + "docutils>=0.21.2", "flaky", "MarkupSafe>=0.23", "oauthlib", @@ -92,6 +91,33 @@ test = [ [project.scripts] jirashell = "jira.jirashell:main" +[tool.codespell] +check-filenames = true +check-hidden = true +quiet-level = 0 +write-changes = true +enable-colors = true +skip = [ + "./.eggs", + "./.git", + "./.mypy_cache", + "./.tox", + "./build", + "./docs/build", + "./node_modules", + "./pip-wheel-metadata", + "./tests/icon.png", + ".DS_Store", + ".ruff_cache", + "AUTHORS", + "ChangeLog", + "__pycache__", + "coverage.xml", + "dist", +] +builtin = ["clear", "rare", "usage", "names", "code"] +ignore-words = [".config/dictionary.txt"] + [tool.files] packages = """ jira""" @@ -136,7 +162,7 @@ filterwarnings = ["ignore::pytest.PytestWarning"] markers = ["allow_on_cloud: opt in for the test to run on Jira Cloud"] [tool.mypy] -python_version = "3.8" +python_version = "3.9" warn_unused_configs = true namespace_packages = true check_untyped_defs = true @@ -150,8 +176,8 @@ disable_error_code = "annotation-unchecked" # Same as Black. line-length = 88 -# Assume Python 3.8. (minimum supported) -target-version = "py38" +# Assume Python 3.9 (minimum supported) +target-version = "py39" # The source code paths to consider, e.g., when resolving first- vs. third-party imports src = ["jira", "tests"] @@ -177,6 +203,8 @@ ignore = [ "D401", "D402", "D417", + "UP006", + "UP035", ] # Allow unused variables when underscore-prefixed. diff --git a/tests/resources/test_board.py b/tests/resources/test_board.py index 8393d43cf..183d84fe2 100644 --- a/tests/resources/test_board.py +++ b/tests/resources/test_board.py @@ -1,7 +1,7 @@ from __future__ import annotations +from collections.abc import Iterator from contextlib import contextmanager -from typing import Iterator from jira.resources import Board from tests.conftest import JiraTestCase, rndstr diff --git a/tests/resources/test_epic.py b/tests/resources/test_epic.py index 42885cee2..8d82c2b86 100644 --- a/tests/resources/test_epic.py +++ b/tests/resources/test_epic.py @@ -1,8 +1,8 @@ from __future__ import annotations +from collections.abc import Iterator from contextlib import contextmanager from functools import cached_property -from typing import Iterator from parameterized import parameterized @@ -59,5 +59,5 @@ def test_add_issues_to_epic(self, name: str, input_type): with self.make_epic() as new_epic: self.jira.add_issues_to_epic( new_epic.id, - ",".join(issue_list) if input_type == str else issue_list, + ",".join(issue_list) if input_type == str else issue_list, # noqa: E721 ) diff --git a/tests/resources/test_issue.py b/tests/resources/test_issue.py index bd30c95a3..a496edb2e 100644 --- a/tests/resources/test_issue.py +++ b/tests/resources/test_issue.py @@ -21,18 +21,18 @@ def test_issue(self): self.assertEqual(issue.fields.summary, f"issue 1 from {self.project_b}") def test_issue_search_finds_issue(self): - issues = self.jira.search_issues("key=%s" % self.issue_1) + issues = self.jira.search_issues(f"key={self.issue_1}") self.assertEqual(self.issue_1, issues[0].key) def test_issue_search_return_type(self): - issues = self.jira.search_issues("key=%s" % self.issue_1) + issues = self.jira.search_issues(f"key={self.issue_1}") self.assertIsInstance(issues, list) - issues = self.jira.search_issues("key=%s" % self.issue_1, json_result=True) + issues = self.jira.search_issues(f"key={self.issue_1}", json_result=True) self.assertIsInstance(issues, dict) def test_issue_search_only_includes_provided_fields(self): issues = self.jira.search_issues( - "key=%s" % self.issue_1, fields="comment,assignee" + f"key={self.issue_1}", fields="comment,assignee" ) self.assertTrue(hasattr(issues[0].fields, "comment")) self.assertTrue(hasattr(issues[0].fields, "assignee")) diff --git a/tests/resources/test_pinned_comment.py b/tests/resources/test_pinned_comment.py new file mode 100644 index 000000000..5170d89f6 --- /dev/null +++ b/tests/resources/test_pinned_comment.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from tests.conftest import JiraTestCase + + +class PinnedCommentTests(JiraTestCase): + def setUp(self): + JiraTestCase.setUp(self) + self.issue_1_key = self.test_manager.project_b_issue1 + self.issue_2_key = self.test_manager.project_b_issue2 + self.issue_3_key = self.test_manager.project_b_issue3 + + def tearDown(self) -> None: + for issue in [self.issue_1_key, self.issue_2_key, self.issue_3_key]: + for comment in self.jira.comments(issue): + comment.delete() + + def test_pincomments(self): + for issue in [self.issue_1_key, self.jira.issue(self.issue_2_key)]: + self.jira.issue(issue) + comment1 = self.jira.add_comment(issue, "First comment") + self.jira.pin_comment(comment1.id, True) + comment2 = self.jira.add_comment(issue, "Second comment") + self.jira.pin_comment(comment2.id, True) + pinned_comments = self.jira.pinned_comments(issue) + assert pinned_comments[0].comment.body == "First comment" + assert pinned_comments[1].comment.body == "Second comment" + self.jira.pin_comment(comment1.id, False) + pinned_comments = self.jira.pinned_comments(issue) + assert len(pinned_comments) == 1 + assert pinned_comments[0].comment.body == "Second comment" + self.jira.pin_comment(comment2.id, False) + pinned_comments = self.jira.pinned_comments(issue) + assert len(pinned_comments) == 0 diff --git a/tests/resources/test_sprint.py b/tests/resources/test_sprint.py index 83c0d43b1..2ccb14837 100644 --- a/tests/resources/test_sprint.py +++ b/tests/resources/test_sprint.py @@ -1,8 +1,8 @@ from __future__ import annotations +from collections.abc import Iterator from contextlib import contextmanager from functools import lru_cache -from typing import Iterator import pytest as pytest diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 09f6c59e2..088ff6dc1 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -21,7 +21,7 @@ class ExceptionsTests(unittest.TestCase): class MockResponse(Response): def __init__( self, - headers: dict = None, + headers: dict | None = None, text: str = "", status_code: int = DUMMY_STATUS_CODE, url: str = DUMMY_URL, @@ -43,7 +43,7 @@ def text(self, new_text): class MalformedMockResponse: def __init__( self, - headers: dict = None, + headers: dict | None = None, text: str = "", status_code: int = DUMMY_STATUS_CODE, url: str = DUMMY_URL, @@ -116,9 +116,11 @@ def test_jira_error_log_to_tempfile_if_env_var_set(self): # WHEN: a JIRAError's __str__ method is called and # log details are expected to be sent to the tempfile - with patch.dict("os.environ", env_vars), patch( - f"{PATCH_BASE}.tempfile.mkstemp", autospec=True - ) as mock_mkstemp, patch(f"{PATCH_BASE}.open", mocked_open): + with ( + patch.dict("os.environ", env_vars), + patch(f"{PATCH_BASE}.tempfile.mkstemp", autospec=True) as mock_mkstemp, + patch(f"{PATCH_BASE}.open", mocked_open), + ): mock_mkstemp.return_value = 0, str(test_jira_error_filename) str(JIRAError(response=self.MockResponse(text=DUMMY_TEXT))) @@ -137,9 +139,11 @@ def test_jira_error_log_to_tempfile_not_used_if_env_var_not_set(self): mocked_open = mock_open() # WHEN: a JIRAError's __str__ method is called - with patch.dict("os.environ", env_vars), patch( - f"{PATCH_BASE}.tempfile.mkstemp", autospec=True - ) as mock_mkstemp, patch(f"{PATCH_BASE}.open", mocked_open): + with ( + patch.dict("os.environ", env_vars), + patch(f"{PATCH_BASE}.tempfile.mkstemp", autospec=True) as mock_mkstemp, + patch(f"{PATCH_BASE}.open", mocked_open), + ): mock_mkstemp.return_value = 0, str(test_jira_error_filename) str(JIRAError(response=self.MockResponse(text=DUMMY_TEXT))) diff --git a/tests/test_resilientsession.py b/tests/test_resilientsession.py index 4a7bbd800..7762ec8fa 100644 --- a/tests/test_resilientsession.py +++ b/tests/test_resilientsession.py @@ -159,6 +159,12 @@ def test_status_codes_retries( (500, {}, '{"errorMessages": "err1"}', ["err1"]), (500, {}, '{"errorMessages": ["err1", "err2"]}', ["err1", "err2"]), (500, {}, '{"errors": {"code1": "err1", "code2": "err2"}}', ["err1", "err2"]), + ( + 500, + {}, + '{"errorMessages": [], "errors": {"code1": "err1", "code2": "err2"}}', + ["err1", "err2"], + ), ] diff --git a/tests/tests.py b/tests/tests.py index 67740daf9..237cc0a3b 100755 --- a/tests/tests.py +++ b/tests/tests.py @@ -23,7 +23,6 @@ from parameterized import parameterized from jira import JIRA, Issue, JIRAError -from jira.client import ResultList from jira.resources import Dashboard, Resource, cls_for_resource from tests.conftest import JiraTestCase, allow_on_cloud, rndpassword @@ -231,11 +230,17 @@ def setUp(self): def test_search_issues(self): issues = self.jira.search_issues(f"project={self.project_b}") - issues = cast(ResultList[Issue], issues) self.assertLessEqual(len(issues), 50) # default maxResults for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) + def test_search_issues_json(self): + result = self.jira.search_issues(f"project={self.project_b}", json_result=True) + issues = result["issues"] + self.assertLessEqual(len(issues), 50) # default maxResults + for issue in issues: + self.assertTrue(issue["key"].startswith(self.project_b)) + def test_search_issues_async(self): original_val = self.jira._options["async"] try: @@ -243,7 +248,6 @@ def test_search_issues_async(self): issues = self.jira.search_issues( f"project={self.project_b}", maxResults=False ) - issues = cast(ResultList[Issue], issues) self.assertEqual(len(issues), issues.total) for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) @@ -263,7 +267,6 @@ def test_search_issues_startat(self): def test_search_issues_field_limiting(self): issues = self.jira.search_issues(f"key={self.issue}", fields="summary,comment") - issues = cast(ResultList[Issue], issues) self.assertTrue(hasattr(issues[0].fields, "summary")) self.assertTrue(hasattr(issues[0].fields, "comment")) self.assertFalse(hasattr(issues[0].fields, "reporter")) @@ -292,7 +295,6 @@ def test_search_issues_fields_translating(self): def test_search_issues_expand(self): issues = self.jira.search_issues(f"key={self.issue}", expand="changelog") - issues = cast(ResultList[Issue], issues) # self.assertTrue(hasattr(issues[0], 'names')) self.assertEqual(len(issues), 1) self.assertFalse(hasattr(issues[0], "editmeta")) @@ -304,7 +306,6 @@ def test_search_issues_use_post(self): with pytest.raises(JIRAError): self.jira.search_issues(long_jql) issues = self.jira.search_issues(long_jql, use_post=True) - issues = cast(ResultList[Issue], issues) self.assertEqual(len(issues), 1) self.assertEqual(issues[0].key, self.issue) @@ -503,7 +504,7 @@ def _calculate_calls_for_fetch_pages( total: int, max_results: int, batch_size: int | None, - default: int | None = 10, + default: int = 10, ): """Returns expected query parameters for specified search-issues arguments.""" if not max_results: diff --git a/tox.ini b/tox.ini index b398da265..e11430199 100644 --- a/tox.ini +++ b/tox.ini @@ -2,24 +2,25 @@ minversion = 4.0 isolated_build = True requires = - # plugins disabled until they gets compatible with tox v4 - # tox-extra - # tox-pyenv + tox-extra envlist = + lint + pkg + docs + py312 py311 py310 py39 - py38 ignore_basepython_conflict = True skip_missing_interpreters = True skipdist = True [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 [testenv] @@ -69,13 +70,13 @@ allowlist_externals = description = Update dependency lock files # Force it to use oldest supported version of python or we would lose ability # to get pinning correctly. -basepython = python3.8 +basepython = python3.9 skip_install = true deps = pip-tools >= 6.4.0 pre-commit >= 2.13.0 commands = - pip-compile --upgrade -o constraints.txt setup.cfg --extra cli --extra docs --extra opt --extra async --extra test --strip-extras + pip-compile --upgrade -o constraints.txt --extra cli --extra docs --extra opt --extra async --extra test --strip-extras {envpython} -m pre_commit autoupdate [testenv:docs] @@ -89,7 +90,9 @@ setenv = PYTHONHTTPSVERIFY=0 commands = sphinx-build \ - -a -n -v -W --keep-going \ + --verbose \ + --write-all \ + --nitpicky --fail-on-warning \ -b html --color \ -d "{toxworkdir}/docs_doctree" \ docs/ "{toxworkdir}/docs_out" @@ -99,7 +102,7 @@ commands = 'import pathlib; '\ 'docs_dir = pathlib.Path(r"{toxworkdir}") / "docs_out"; index_file = docs_dir / "index.html"; print(f"\nDocumentation available under `file://\{index_file\}`\n\nTo serve docs, use `python3 -m http.server --directory \{docs_dir\} 0`\n")' -[testenv:packaging] +[testenv:pkg] basepython = python3 description = Build package, verify metadata, install package and assert behavior when ansible is missing.