diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd69577d1..c938a174b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,6 @@ jobs: noxenv: "tests-3.11(no-extras)" posargs: coverage github exclude: - - os: windows-latest - noxenv: readme - os: windows-latest noxenv: "docs(dirhtml)" - os: windows-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0f58d1a83..03d523ec3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: args: [--fix, lf] - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.0.276" + rev: "v0.0.277" hooks: - id: ruff - repo: https://github.com/PyCQA/isort @@ -24,7 +24,7 @@ repos: hooks: - id: isort - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v3.0.0-alpha.9-for-vscode" + rev: "v3.0.0" hooks: - id: prettier exclude: "^jsonschema/benchmarks/issue232/issue.json$" diff --git a/CHANGELOG.rst b/CHANGELOG.rst index da49bc3a7..5bae3bee5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,12 @@ +v4.18.1 +======= + +* Fix a regression with ``jsonschema.RefResolver`` based resolution when used in combination with a custom validation dialect (via ``jsonschema.validators.create``). + v4.18.0 ======= -This release majorly rehaul's the way in which JSON Schema reference resolution is configured. +This release majorly rehauls the way in which JSON Schema reference resolution is configured. It does so in a way that *should* be backwards compatible, preserving old behavior whilst emitting deprecation warnings. * ``jsonschema.RefResolver`` is now deprecated in favor of the new `referencing library `_. diff --git a/jsonschema/_types.py b/jsonschema/_types.py index d142810d0..dae83d00f 100644 --- a/jsonschema/_types.py +++ b/jsonschema/_types.py @@ -3,13 +3,13 @@ from typing import Any, Callable, Mapping import numbers +from attrs import evolve, field, frozen from rpds import HashTrieMap -import attr from jsonschema.exceptions import UndefinedTypeCheck -# unfortunately, the type of HashTrieMap is generic, and if used as the attr.ib +# unfortunately, the type of HashTrieMap is generic, and if used as an attrs # converter, the generic type is presented to mypy, which then fails to match # the concrete type of a type checker mapping # this "do nothing" wrapper presents the correct information to mypy @@ -57,7 +57,7 @@ def is_any(checker, instance): return True -@attr.s(frozen=True, repr=False) +@frozen(repr=False) class TypeChecker: """ A :kw:`type` property checker. @@ -80,10 +80,7 @@ class TypeChecker: _type_checkers: HashTrieMap[ str, Callable[[TypeChecker, Any], bool], - ] = attr.ib( - default=HashTrieMap(), - converter=_typed_map_converter, - ) + ] = field(default=HashTrieMap(), converter=_typed_map_converter) def __repr__(self): types = ", ".join(repr(k) for k in sorted(self._type_checkers)) @@ -146,7 +143,7 @@ def redefine_many(self, definitions=()) -> TypeChecker: A dictionary mapping types to their checking functions. """ type_checkers = self._type_checkers.update(definitions) - return attr.evolve(self, type_checkers=type_checkers) + return evolve(self, type_checkers=type_checkers) def remove(self, *types) -> TypeChecker: """ @@ -170,7 +167,7 @@ def remove(self, *types) -> TypeChecker: type_checkers = type_checkers.remove(each) except KeyError: raise UndefinedTypeCheck(each) - return attr.evolve(self, type_checkers=type_checkers) + return evolve(self, type_checkers=type_checkers) draft3_type_checker = TypeChecker( diff --git a/jsonschema/cli.py b/jsonschema/cli.py index 65bbb9da5..e8f671ca2 100644 --- a/jsonschema/cli.py +++ b/jsonschema/cli.py @@ -16,7 +16,7 @@ except ImportError: from pkgutil_resolve_name import resolve_name # type: ignore -import attr +from attrs import define, field from jsonschema.exceptions import SchemaError from jsonschema.validators import _RefResolver, validator_for @@ -36,12 +36,12 @@ class _CannotLoadFile(Exception): pass -@attr.s +@define class _Outputter: - _formatter = attr.ib() - _stdout = attr.ib() - _stderr = attr.ib() + _formatter = field() + _stdout = field() + _stderr = field() @classmethod def from_arguments(cls, arguments, stdout, stderr): @@ -78,7 +78,7 @@ def validation_success(self, **kwargs): self._stdout.write(self._formatter.validation_success(**kwargs)) -@attr.s +@define class _PrettyFormatter: _ERROR_MSG = dedent( @@ -120,10 +120,10 @@ def validation_success(self, instance_path): return self._SUCCESS_MSG.format(path=instance_path) -@attr.s +@define class _PlainFormatter: - _error_format = attr.ib() + _error_format = field() def filenotfound_error(self, path, exc_info): return "{!r} does not exist.\n".format(path) diff --git a/jsonschema/exceptions.py b/jsonschema/exceptions.py index a64fd9808..80679c1b5 100644 --- a/jsonschema/exceptions.py +++ b/jsonschema/exceptions.py @@ -11,8 +11,8 @@ import itertools import warnings +from attrs import define from referencing.exceptions import Unresolvable as _Unresolvable -import attr from jsonschema import _utils @@ -193,7 +193,7 @@ class SchemaError(_Error): _word_for_instance_in_error_message = "schema" -@attr.s(hash=True) +@define(slots=False) class _RefResolutionError(Exception): """ A ref could not be resolved. @@ -205,7 +205,12 @@ class _RefResolutionError(Exception): "directly catch referencing.exceptions.Unresolvable." ) - _cause = attr.ib() + _cause: Exception + + def __eq__(self, other): + if self.__class__ is not other.__class__: + return NotImplemented + return self._cause == other._cause def __str__(self): return str(self._cause) diff --git a/jsonschema/tests/test_validators.py b/jsonschema/tests/test_validators.py index 3f16d9078..5a4f87f3b 100644 --- a/jsonschema/tests/test_validators.py +++ b/jsonschema/tests/test_validators.py @@ -4,6 +4,7 @@ from contextlib import contextmanager from decimal import Decimal from io import BytesIO +from typing import Any from unittest import TestCase, mock from urllib.request import pathname2url import json @@ -12,8 +13,8 @@ import tempfile import warnings +from attrs import define, field from referencing.jsonschema import DRAFT202012 -import attr import referencing.exceptions from jsonschema import ( @@ -1570,10 +1571,10 @@ def test_evolve_with_subclass(self): """ with self.assertWarns(DeprecationWarning): - @attr.s + @define class OhNo(self.Validator): - foo = attr.ib(factory=lambda: [1, 2, 3]) - _bar = attr.ib(default=37) + foo = field(factory=lambda: [1, 2, 3]) + _bar = field(default=37) validator = OhNo({}, bar=12) self.assertEqual(validator.foo, [1, 2, 3]) @@ -2372,6 +2373,28 @@ def test_pointer_within_schema_with_different_id(self): validator = validators.Draft7Validator(another, resolver=two) self.assertFalse(validator.is_valid({"maxLength": "foo"})) + def test_newly_created_validator_with_ref_resolver(self): + """ + See https://github.com/python-jsonschema/jsonschema/issues/1061#issuecomment-1624266555. + """ # noqa: E501 + + def handle(uri): + self.assertEqual(uri, "http://example.com/foo") + return {"type": "integer"} + + resolver = validators._RefResolver("", {}, handlers={"http": handle}) + Validator = validators.create( + meta_schema={}, + validators=validators.Draft4Validator.VALIDATORS, + ) + schema = {"$id": "http://example.com/bar", "$ref": "foo"} + validator = Validator(schema, resolver=resolver) + self.assertEqual( + (validator.is_valid({}), validator.is_valid(37)), + (False, True), + ) + + def sorted_errors(errors): def key(error): @@ -2382,10 +2405,10 @@ def key(error): return sorted(errors, key=key) -@attr.s +@define class ReallyFakeRequests: - _responses = attr.ib() + _responses: dict[str, Any] def get(self, url): response = self._responses.get(url) @@ -2394,10 +2417,10 @@ def get(self, url): return _ReallyFakeJSONResponse(json.dumps(response)) -@attr.s +@define class _ReallyFakeJSONResponse: - _response = attr.ib() + _response: str def json(self): return json.loads(self._response) diff --git a/jsonschema/validators.py b/jsonschema/validators.py index bed919e2e..b8a9c141a 100644 --- a/jsonschema/validators.py +++ b/jsonschema/validators.py @@ -275,6 +275,11 @@ def __attrs_post_init__(self): resource = specification.create_resource(self.schema) self._resolver = registry.resolver_with_root(resource) + # REMOVEME: Legacy ref resolution state management. + push_scope = getattr(self._ref_resolver, "push_scope", None) + if push_scope is not None: + push_scope(id_of(self.schema)) + @classmethod def check_schema(cls, schema, format_checker=_UNSET): Validator = validator_for(cls.META_SCHEMA, default=cls) diff --git a/noxfile.py b/noxfile.py index 1c2571717..b57b5acb0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,4 +1,5 @@ from pathlib import Path +from tempfile import TemporaryDirectory import os import nox @@ -106,18 +107,13 @@ def audit(session, installable): @session(tags=["build"]) def build(session): - session.install("build") - tmpdir = session.create_tmp() - session.run("python", "-m", "build", ROOT, "--outdir", tmpdir) - - -@session(tags=["style"]) -def readme(session): session.install("build", "docutils", "twine") - tmpdir = session.create_tmp() - session.run("python", "-m", "build", ROOT, "--outdir", tmpdir) - session.run("python", "-m", "twine", "check", "--strict", tmpdir + "/*") - session.run("rst2html5.py", "--halt=warning", CHANGELOG, "/dev/null") + with TemporaryDirectory() as tmpdir: + session.run("python", "-m", "build", ROOT, "--outdir", tmpdir) + session.run("twine", "check", "--strict", tmpdir + "/*") + session.run( + "python", "-m", "docutils", "--strict", CHANGELOG, os.devnull, + ) @session() @@ -154,20 +150,21 @@ def typing(session): ) def docs(session, builder): session.install("-r", DOCS / "requirements.txt") - tmpdir = Path(session.create_tmp()) - argv = ["-n", "-T", "-W"] - if builder != "spelling": - argv += ["-q"] - session.run( - "python", - "-m", - "sphinx", - "-b", - builder, - DOCS, - tmpdir / builder, - *argv, - ) + with TemporaryDirectory() as tmpdir_str: + tmpdir = Path(tmpdir_str) + argv = ["-n", "-T", "-W"] + if builder != "spelling": + argv += ["-q"] + session.run( + "python", + "-m", + "sphinx", + "-b", + builder, + DOCS, + tmpdir / builder, + *argv, + ) @session(tags=["docs", "style"], name="docs(style)")