From b3f4bd91bc9e22a65a0f1e96b59d4c43be52b173 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 23 Oct 2025 20:36:49 -0500 Subject: [PATCH] Thoroughly test type annotations, and resolve errors Type checking across all supported Pythons revealed errors: * Python 3.9 doesn't have `typing.TypeAlias`. This is fixed by importing `typing_extensions.TypeAlias`. * Python 3.9 doesn't support type alias `x | y` union syntax. This is fixed by using `Union[x, y]` syntax. * Setting `warn-return-any = true` revealed that when `cryptography` isn't installed, `RSAPrivateKey.sign()` has an unknown signature. The return type cannot be `cast()` because it's redundant if `cryptography` is installed. This is fixed by adding `signature: bytes = key.sign()` and then returning `signature`. * All disabled error codes have been removed. * The pre-commit hook for mypy has been removed. It's less robust than running the test suite. * All functions and methods are now fully type-annotated. --- .pre-commit-config.yaml | 6 ---- CHANGELOG.rst | 3 ++ jwt/algorithms.py | 67 ++++++++++++++++++++++++++--------------- jwt/api_jwt.py | 20 +++++++----- jwt/jwks_client.py | 5 ++- pyproject.toml | 14 ++------- tox.ini | 13 ++++++++ 7 files changed, 76 insertions(+), 52 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2bbbf47b..1b466a3d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,12 +36,6 @@ repos: - id: check-manifest args: [--no-build-isolation] - - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.18.2" - hooks: - - id: mypy - additional_dependencies: [cryptography>=3.4.0] - - repo: https://github.com/abravalheri/validate-pyproject rev: "v0.24.1" hooks: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b497a84c..5dcc29ab 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,9 @@ Fixed - Declare float supported type for lifespan and timeout by @nikitagashkov in `#1068 `__ - Fix ``SyntaxWarning``\s/``DeprecationWarning``\s caused by invalid escape sequences by @kurtmckee in `#1103 `__ - Development: Build a shared wheel once to speed up test suite setup times by @kurtmckee in `#1114 `__ +- Development: Test type annotations across all supported Python versions, + increase the strictness of the type checking, and remove the mypy pre-commit hook + by @kurtmckee in `#1112 `__ Added ~~~~~ diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 09e2a355..2020d561 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -5,7 +5,16 @@ import json import os from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, ClassVar, Literal, NoReturn, cast, overload +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Literal, + NoReturn, + Union, + cast, + overload, +) from .exceptions import InvalidKeyError from .types import HashlibHash, JWKDict @@ -93,7 +102,13 @@ ) if TYPE_CHECKING or bool(os.getenv("SPHINX_BUILD", "")): - from typing import TypeAlias + import sys + + if sys.version_info >= (3, 10): + from typing import TypeAlias + else: + # Python 3.9 and lower + from typing_extensions import TypeAlias from cryptography.hazmat.primitives.asymmetric.types import ( PrivateKeyTypes, @@ -101,23 +116,22 @@ ) # Type aliases for convenience in algorithms method signatures - AllowedRSAKeys: TypeAlias = RSAPrivateKey | RSAPublicKey - AllowedECKeys: TypeAlias = EllipticCurvePrivateKey | EllipticCurvePublicKey - AllowedOKPKeys: TypeAlias = ( - Ed25519PrivateKey | Ed25519PublicKey | Ed448PrivateKey | Ed448PublicKey - ) - AllowedKeys: TypeAlias = AllowedRSAKeys | AllowedECKeys | AllowedOKPKeys + AllowedRSAKeys: TypeAlias = Union[RSAPrivateKey, RSAPublicKey] + AllowedECKeys: TypeAlias = Union[ + EllipticCurvePrivateKey, EllipticCurvePublicKey + ] + AllowedOKPKeys: TypeAlias = Union[ + Ed25519PrivateKey, Ed25519PublicKey, Ed448PrivateKey, Ed448PublicKey + ] + AllowedKeys: TypeAlias = Union[AllowedRSAKeys, AllowedECKeys, AllowedOKPKeys] #: Type alias for allowed ``cryptography`` private keys (requires ``cryptography`` to be installed) - AllowedPrivateKeys: TypeAlias = ( - RSAPrivateKey - | EllipticCurvePrivateKey - | Ed25519PrivateKey - | Ed448PrivateKey - ) + AllowedPrivateKeys: TypeAlias = Union[ + RSAPrivateKey, EllipticCurvePrivateKey, Ed25519PrivateKey, Ed448PrivateKey + ] #: Type alias for allowed ``cryptography`` public keys (requires ``cryptography`` to be installed) - AllowedPublicKeys: TypeAlias = ( - RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey | Ed448PublicKey - ) + AllowedPublicKeys: TypeAlias = Union[ + RSAPublicKey, EllipticCurvePublicKey, Ed25519PublicKey, Ed448PublicKey + ] has_crypto = True except ModuleNotFoundError: @@ -204,7 +218,7 @@ def compute_hash_digest(self, bytestr: bytes) -> bytes: else: return bytes(hash_alg(bytestr).digest()) - def check_crypto_key_type(self, key: PublicKeyTypes | PrivateKeyTypes): + def check_crypto_key_type(self, key: PublicKeyTypes | PrivateKeyTypes) -> None: """Check that the key belongs to the right cryptographic family. Note that this method only works when ``cryptography`` is installed. @@ -251,16 +265,18 @@ def verify(self, msg: bytes, key: Any, sig: bytes) -> bool: @overload @staticmethod @abstractmethod - def to_jwk(key_obj, as_dict: Literal[True]) -> JWKDict: ... + def to_jwk(key_obj: Any, as_dict: Literal[True]) -> JWKDict: ... # pragma: no cover @overload @staticmethod @abstractmethod - def to_jwk(key_obj, as_dict: Literal[False] = False) -> str: ... + def to_jwk( + key_obj: Any, as_dict: Literal[False] = False + ) -> str: ... # pragma: no cover @staticmethod @abstractmethod - def to_jwk(key_obj, as_dict: bool = False) -> JWKDict | str: + def to_jwk(key_obj: Any, as_dict: bool = False) -> JWKDict | str: """ Serializes a given key into a JWK """ @@ -538,7 +554,8 @@ def from_jwk(jwk: str | JWKDict) -> AllowedRSAKeys: raise InvalidKeyError("Not a public or private key") def sign(self, msg: bytes, key: RSAPrivateKey) -> bytes: - return key.sign(msg, padding.PKCS1v15(), self.hash_alg()) + signature: bytes = key.sign(msg, padding.PKCS1v15(), self.hash_alg()) + return signature def verify(self, msg: bytes, key: RSAPublicKey, sig: bytes) -> bool: try: @@ -742,7 +759,7 @@ class RSAPSSAlgorithm(RSAAlgorithm): """ def sign(self, msg: bytes, key: RSAPrivateKey) -> bytes: - return key.sign( + signature: bytes = key.sign( msg, padding.PSS( mgf=padding.MGF1(self.hash_alg()), @@ -750,6 +767,7 @@ def sign(self, msg: bytes, key: RSAPrivateKey) -> bytes: ), self.hash_alg(), ) + return signature def verify(self, msg: bytes, key: RSAPublicKey, sig: bytes) -> bool: try: @@ -811,7 +829,8 @@ def sign( :return bytes signature: The signature, as bytes """ msg_bytes = msg.encode("utf-8") if isinstance(msg, str) else msg - return key.sign(msg_bytes) + signature: bytes = key.sign(msg_bytes) + return signature def verify( self, msg: str | bytes, key: AllowedOKPKeys, sig: str | bytes diff --git a/jwt/api_jwt.py b/jwt/api_jwt.py index d3686029..07862c23 100644 --- a/jwt/api_jwt.py +++ b/jwt/api_jwt.py @@ -6,7 +6,7 @@ from calendar import timegm from collections.abc import Container, Iterable, Sequence from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Union, cast from . import api_jws from .exceptions import ( @@ -23,7 +23,13 @@ from .warnings import RemovedInPyjwt3Warning if TYPE_CHECKING or bool(os.getenv("SPHINX_BUILD", "")): - from typing import TypeAlias + import sys + + if sys.version_info >= (3, 10): + from typing import TypeAlias + else: + # Python 3.9 and lower + from typing_extensions import TypeAlias from .algorithms import has_crypto from .api_jwk import PyJWK @@ -32,11 +38,11 @@ if has_crypto: from .algorithms import AllowedPrivateKeys, AllowedPublicKeys - AllowedPrivateKeyTypes: TypeAlias = AllowedPrivateKeys | PyJWK | str | bytes # type: ignore - AllowedPublicKeyTypes: TypeAlias = AllowedPublicKeys | PyJWK | str | bytes # type: ignore + AllowedPrivateKeyTypes: TypeAlias = Union[AllowedPrivateKeys, PyJWK, str, bytes] + AllowedPublicKeyTypes: TypeAlias = Union[AllowedPublicKeys, PyJWK, str, bytes] else: - AllowedPrivateKeyTypes: TypeAlias = PyJWK | str | bytes # type: ignore - AllowedPublicKeyTypes: TypeAlias = PyJWK | str | bytes # type: ignore + AllowedPrivateKeyTypes: TypeAlias = Union[PyJWK, str, bytes] # type: ignore + AllowedPublicKeyTypes: TypeAlias = Union[PyJWK, str, bytes] # type: ignore class PyJWT: @@ -360,7 +366,7 @@ def decode( issuer=issuer, leeway=leeway, ) - return decoded["payload"] + return cast(dict[str, Any], decoded["payload"]) def _validate_claims( self, diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index 5e33bfa5..533b72f0 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -46,10 +46,9 @@ def __init__( if cache_keys: # Cache signing keys + get_signing_key = lru_cache(maxsize=max_cached_keys)(self.get_signing_key) # Ignore mypy (https://github.com/python/mypy/issues/2427) - self.get_signing_key = lru_cache(maxsize=max_cached_keys)( - self.get_signing_key - ) # type: ignore + self.get_signing_key = get_signing_key # type: ignore[method-assign] def fetch_data(self) -> Any: jwk_set: Any = None diff --git a/pyproject.toml b/pyproject.toml index 84cb2afd..bc6fee01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,20 +96,10 @@ combine_as_imports = true profile = "black" [tool.mypy] -allow_incomplete_defs = true -allow_untyped_defs = true -disable_error_code = [ - "method-assign", - "unused-ignore", -] +packages = "jwt" ignore_missing_imports = true -no_implicit_optional = true -overrides = [ - { disallow_untyped_calls = false, module = "tests.*" }, -] -python_version = 3.11 strict = true -warn_return_any = false +warn_return_any = true warn_unused_ignores = true [tool.setuptools] diff --git a/tox.ini b/tox.ini index f5e25334..8424daf6 100644 --- a/tox.ini +++ b/tox.ini @@ -27,10 +27,13 @@ envlist = lint typing py{39,310,311,312,313,314,py39,py310,py311}-{crypto,nocrypto} + py{39,310,311,312,313,314}{,-crypto}-mypy docs pypi-description coverage-report isolated_build = True +labels = + mypy = py{39,310,311,312,313,314}{,-crypto}-mypy [testenv] @@ -60,6 +63,16 @@ commands = python -m doctest README.rst docs/usage.rst +[testenv:py{39,310,311,312,313,314}{,-crypto}-mypy] +extras = + crypto: crypto +deps = + mypy +set_env = + MYPY_FORCE_COLOR=1 +commands = + mypy + [testenv:lint] basepython = python3.9 extras = dev