From e30b48e049c46ca27e2bee54632dda8855c67672 Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Thu, 6 Jul 2023 11:37:23 +0200 Subject: [PATCH 1/6] Minor grammar fix. --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index da49bc3a7..e4a229466 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ 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 `_. From 7046da13cf41fcfa355fcd68097e75fa03c1f0fc Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Thu, 6 Jul 2023 13:25:31 +0200 Subject: [PATCH 2/6] Make everywhere use the newer attrs APIs. --- jsonschema/_types.py | 15 ++++++--------- jsonschema/cli.py | 16 ++++++++-------- jsonschema/exceptions.py | 11 ++++++++--- jsonschema/tests/test_validators.py | 17 +++++++++-------- 4 files changed, 31 insertions(+), 28 deletions(-) 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..f7f541467 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]) @@ -2382,10 +2383,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 +2395,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) From 4817d36a72d437083e754411e4052bd635fc5bdc Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Sun, 9 Jul 2023 15:18:22 +0200 Subject: [PATCH 3/6] Don't use nox.session.create_tmp. It's basically a footgun, in that it: * doesn't create a pseudorandom temporary directory, it just gives you the path 'tmp/' * thereby then doesn't create separate directories if you call it multiple times * mutates the global (shell) environment state by setting TMPDIR to this 'new' directory so other processes can now 'accidentally' end up sticking things in it (In particular I was really confused how/why non-distribution files were being plopped into my python -m build's outdir, but it was because TMPDIR was sticking around) --- noxfile.py | 45 ++++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/noxfile.py b/noxfile.py index 1c2571717..9993dcbb6 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,11 @@ 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("rst2html5.py", "--halt=warning", CHANGELOG, "/dev/null") @session() @@ -154,20 +148,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)") From 273d4dd6ca156bff5da6f45d552ad1104d639e10 Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Sun, 9 Jul 2023 15:43:23 +0200 Subject: [PATCH 4/6] Twewak the build noxenv again. Hopefully slightly more resilient for Windows. --- .github/workflows/ci.yml | 2 -- noxfile.py | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) 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/noxfile.py b/noxfile.py index 9993dcbb6..b57b5acb0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -111,7 +111,9 @@ def build(session): with TemporaryDirectory() as tmpdir: session.run("python", "-m", "build", ROOT, "--outdir", tmpdir) session.run("twine", "check", "--strict", tmpdir + "/*") - session.run("rst2html5.py", "--halt=warning", CHANGELOG, "/dev/null") + session.run( + "python", "-m", "docutils", "--strict", CHANGELOG, os.devnull, + ) @session() From 6edfe242921a9496af7fe5641607a05cf2868052 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Jul 2023 22:30:58 +0000 Subject: [PATCH 5/6] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.0.276 → v0.0.277](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.276...v0.0.277) - [github.com/pre-commit/mirrors-prettier: v3.0.0-alpha.9-for-vscode → v3.0.0](https://github.com/pre-commit/mirrors-prettier/compare/v3.0.0-alpha.9-for-vscode...v3.0.0) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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$" From 90ea77961987da03188f5b9972631f89d20c4798 Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Wed, 12 Jul 2023 11:38:44 +0200 Subject: [PATCH 6/6] Fix a regression with RefResolver-based resolution in newly created drafts We need a bit more state management to serve `RefResolver` until it's fully removed :/ (The handler here is simply to avoid needing to hit some remote reference.) Refs: https://github.com/python-jsonschema/jsonschema/issues/1061#issuecomment-1624266555 --- CHANGELOG.rst | 5 +++++ jsonschema/tests/test_validators.py | 22 ++++++++++++++++++++++ jsonschema/validators.py | 5 +++++ 3 files changed, 32 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e4a229466..5bae3bee5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +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 ======= diff --git a/jsonschema/tests/test_validators.py b/jsonschema/tests/test_validators.py index f7f541467..5a4f87f3b 100644 --- a/jsonschema/tests/test_validators.py +++ b/jsonschema/tests/test_validators.py @@ -2373,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): 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)