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 413227c52..24499a673 100644
--- a/README.rst
+++ b/README.rst
@@ -11,11 +11,6 @@ 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/
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 1bf10cfcb..5ddd168d5 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,
@@ -214,7 +213,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}
@@ -227,7 +226,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,
@@ -470,23 +469,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.
@@ -565,7 +564,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 = {}
@@ -760,7 +758,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
@@ -786,7 +785,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]:
@@ -998,7 +997,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.
@@ -1072,7 +1071,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.
@@ -1096,8 +1095,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
@@ -1146,7 +1144,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(
@@ -1577,10 +1575,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.
@@ -1611,10 +1609,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.
@@ -1644,7 +1642,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:
@@ -2017,7 +2015,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.
@@ -2816,14 +2817,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
@@ -3269,7 +3264,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.
@@ -3284,7 +3279,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:
@@ -3492,6 +3487,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,
@@ -3501,6 +3526,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]:
@@ -3815,7 +3841,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.
@@ -3830,7 +3856,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:
@@ -3988,8 +4014,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:
@@ -4021,9 +4047,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:
@@ -4061,7 +4087,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.
@@ -4086,7 +4114,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:
@@ -4210,7 +4238,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 = {}
@@ -4223,8 +4251,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(
@@ -4236,7 +4265,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
"""
@@ -4248,7 +4277,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
"""
@@ -4256,7 +4285,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
@@ -4343,7 +4372,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,
):
@@ -4432,7 +4461,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"
@@ -4664,6 +4696,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
@@ -4671,12 +4705,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:
@@ -4775,7 +4812,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"
@@ -4790,7 +4827,7 @@ def templates(self) -> dict:
# pprint(templates.keys())
return templates
- @lru_cache(maxsize=None)
+ @cache
def permissionschemes(self):
url = self._get_url("permissionscheme")
@@ -4799,7 +4836,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).
@@ -4813,7 +4850,7 @@ def issue_type_schemes(self) -> list[IssueTypeScheme]:
return data["schemes"]
- @lru_cache(maxsize=None)
+ @cache
def issuesecurityschemes(self):
url = self._get_url("issuesecurityschemes")
@@ -4822,7 +4859,7 @@ def issuesecurityschemes(self):
return data["issueSecuritySchemes"]
- @lru_cache(maxsize=None)
+ @cache
def projectcategories(self):
url = self._get_url("projectCategory")
@@ -4831,7 +4868,7 @@ def projectcategories(self):
return data
- @lru_cache(maxsize=None)
+ @cache
def avatars(self, entity="project"):
url = self._get_url(f"avatar/{entity}/system")
@@ -4840,7 +4877,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")
@@ -4849,7 +4886,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")
@@ -4858,7 +4895,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")
@@ -4867,7 +4904,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")
@@ -4911,16 +4948,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.
@@ -4963,6 +5000,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()
@@ -4972,6 +5011,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
@@ -5087,8 +5128,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,
@@ -5224,8 +5265,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.
@@ -5265,7 +5306,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.
@@ -5298,7 +5339,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.
@@ -5432,7 +5473,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,
@@ -5536,7 +5577,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``.
@@ -5632,3 +5676,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 f040150fa..11dd30b7f 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"))
@@ -271,7 +274,6 @@ def test_search_issues_field_limiting(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"))
@@ -283,7 +285,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)
@@ -482,7 +483,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.