From a916d8f8253baa11bacc60f0868f0bab1e42d526 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 11 Aug 2025 10:45:03 -0500 Subject: [PATCH 1/3] Fix `Validator` protocol init to match runtime The runtime init signature of a validator can be seen with `inspect`: ```pycon >>> import inspect, jsonschema >>> inspect.signature(jsonschema.validators.Draft7Validator.__init__) , _resolver=None) -> None> ``` This aligns the protocol's declared signature with that behavior more exactly with the following changes: `registry` is now keyword-only, not keyword-or-positional, and has a default. `resolver` is added to the declared signature, so users who are using it won't see typing-time discrepancies with the runtime. It is marked as `Any` and commented inline as deprecated, since it's unclear what else we could do to indicate its status. This means that code passing a resolver will continue to type check (previously it would not). `resolver` is the second keyword-or-positional and `format_checker` is the third, meaning that a positional-only caller who passes, for example: `Draft202012Validator(foo, None, bar)` will have `foo` slotted as the schema and `bar` as the `format_checker` This would primarily impact callers with positional-args calling conventions, but is more reflective of what they'll see at runtime. In order to remove `resolver` from the protocol signature, but match the runtime signatures well, some kind of placeholder is needed to indicate `format_checker` as positional-or-keyword. Or else, a large number of overloads for `__init__` could be declared to try to simulate its removal. --- jsonschema/protocols.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/jsonschema/protocols.py b/jsonschema/protocols.py index 0fd993eec..b6288dcc2 100644 --- a/jsonschema/protocols.py +++ b/jsonschema/protocols.py @@ -108,10 +108,11 @@ class Validator(Protocol): def __init__( self, schema: Mapping | bool, - registry: referencing.jsonschema.SchemaRegistry, + resolver: Any = None, # deprecated format_checker: jsonschema.FormatChecker | None = None, - ) -> None: - ... + *, + registry: referencing.jsonschema.SchemaRegistry = ..., + ) -> None: ... @classmethod def check_schema(cls, schema: Mapping | bool) -> None: From 64bc2171624ef201bdbf35e47780348ce30935c5 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Mon, 18 Aug 2025 10:56:49 -0500 Subject: [PATCH 2/3] Add a typing test for the Validator protocol The typing tests are a new subdirectory of `jsonschema/tests/` which are checked under a more stringent mypy configuration (`--warn-unused-ignores`) in order to allow certain kinds of negative tests against the declared types. [^1] The first new test confirms that each validator matches the Validator protocol, and furthermore that this is not vacuously true by way of `Any`. The test failed at first, as the return type of `create()` was not annotated, and therefore under the declared types in `jsonschema`, all of the validators were of type `Any`. To resolve, the return type of `create` is now annotated as `type[Validator]`. [^1]: Technically, the new nox configuration checks these files twice, but only the second check, with `--warn-unused-ignores`, is doing all of the necessary work. --- jsonschema/tests/typing/__init__.py | 0 ..._all_concrete_validators_match_protocol.py | 38 +++++++++++++++++++ jsonschema/validators.py | 4 +- noxfile.py | 4 ++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 jsonschema/tests/typing/__init__.py create mode 100644 jsonschema/tests/typing/test_all_concrete_validators_match_protocol.py diff --git a/jsonschema/tests/typing/__init__.py b/jsonschema/tests/typing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/jsonschema/tests/typing/test_all_concrete_validators_match_protocol.py b/jsonschema/tests/typing/test_all_concrete_validators_match_protocol.py new file mode 100644 index 000000000..63e8bd405 --- /dev/null +++ b/jsonschema/tests/typing/test_all_concrete_validators_match_protocol.py @@ -0,0 +1,38 @@ +""" +This module acts as a test that type checkers will allow each validator +class to be assigned to a variable of type `type[Validator]` + +The assignation is only valid if type checkers recognize each Validator +implementation as a valid implementer of the protocol. +""" +from jsonschema.protocols import Validator +from jsonschema.validators import ( + Draft3Validator, + Draft4Validator, + Draft6Validator, + Draft7Validator, + Draft201909Validator, + Draft202012Validator, +) + +my_validator: type[Validator] + +my_validator = Draft3Validator +my_validator = Draft4Validator +my_validator = Draft6Validator +my_validator = Draft7Validator +my_validator = Draft201909Validator +my_validator = Draft202012Validator + + +# in order to confirm that none of the above were incorrectly typed as 'Any' +# ensure that each of these assignments to a non-validator variable requires an +# ignore +none_var: None + +none_var = Draft3Validator # type: ignore[assignment] +none_var = Draft4Validator # type: ignore[assignment] +none_var = Draft6Validator # type: ignore[assignment] +none_var = Draft7Validator # type: ignore[assignment] +none_var = Draft201909Validator # type: ignore[assignment] +none_var = Draft202012Validator # type: ignore[assignment] diff --git a/jsonschema/validators.py b/jsonschema/validators.py index b8ca3bd45..dbc029fc0 100644 --- a/jsonschema/validators.py +++ b/jsonschema/validators.py @@ -147,7 +147,7 @@ def create( applicable_validators: _typing.ApplicableValidators = methodcaller( "items", ), -): +) -> type[Validator]: """ Create a new validator class. @@ -511,7 +511,7 @@ def is_valid(self, instance, _schema=None): Validator.__name__ = Validator.__qualname__ = f"{safe}Validator" Validator = validates(version)(Validator) # type: ignore[misc] - return Validator + return Validator # type: ignore[return-value] def extend( diff --git a/noxfile.py b/noxfile.py index 411ac185e..3765637b8 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,6 +6,7 @@ ROOT = Path(__file__).parent PACKAGE = ROOT / "jsonschema" +TYPING_TESTS= ROOT / "jsonschema" / "tests" / "typing" BENCHMARKS = PACKAGE / "benchmarks" PYPROJECT = ROOT / "pyproject.toml" CHANGELOG = ROOT / "CHANGELOG.rst" @@ -181,6 +182,9 @@ def typing(session): """ session.install("mypy", "types-requests", ROOT) session.run("mypy", "--config", PYPROJECT, PACKAGE) + session.run( + "mypy", "--config", PYPROJECT, "--warn-unused-ignores", TYPING_TESTS + ) @session(tags=["docs"]) From 1e58409b71a9696b7bf9938ae8a3a48ef95ab29e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:02:25 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 3765637b8..05a238459 100644 --- a/noxfile.py +++ b/noxfile.py @@ -183,7 +183,7 @@ def typing(session): session.install("mypy", "types-requests", ROOT) session.run("mypy", "--config", PYPROJECT, PACKAGE) session.run( - "mypy", "--config", PYPROJECT, "--warn-unused-ignores", TYPING_TESTS + "mypy", "--config", PYPROJECT, "--warn-unused-ignores", TYPING_TESTS, )