diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb3b232ce..a01200b60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,17 @@ name: CI on: push: + branches-ignore: + - "wip*" + tags: + - "v*" pull_request: release: types: [published] schedule: # Daily at 3:21 - cron: "21 3 * * *" + workflow_dispatch: env: PIP_DISABLE_PIP_VERSION_CHECK: "1" @@ -82,6 +87,7 @@ jobs: 3.10 3.11 3.12 + 3.13 pypy3.10 allow-prereleases: true - name: Set up nox diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50b548389..a42390115 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.4.2" + rev: "v0.5.0" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0da30a6b5..a7b9d86eb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,9 @@ +v4.23.0 +======= + +* Do not reorder dictionaries (schemas, instances) that are printed as part of validation errors. +* Declare support for Py3.13 + v4.22.0 ======= diff --git a/docs/errors.rst b/docs/errors.rst index 79c830e9e..9e8046ee6 100644 --- a/docs/errors.rst +++ b/docs/errors.rst @@ -216,8 +216,8 @@ easier debugging. 3 is not valid under any of the given schemas Failed validating 'anyOf' in schema['items']: - {'anyOf': [{'maxLength': 2, 'type': 'string'}, - {'minimum': 5, 'type': 'integer'}]} + {'anyOf': [{'type': 'string', 'maxLength': 2}, + {'type': 'integer', 'minimum': 5}]} On instance[1]: 3 diff --git a/docs/requirements.txt b/docs/requirements.txt index 6b44e8ebb..ca1958ab6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,31 +6,29 @@ # alabaster==0.7.16 # via sphinx -anyascii==0.3.2 - # via sphinx-autoapi -astroid==3.1.0 +astroid==3.2.2 # via sphinx-autoapi attrs==23.2.0 # via # jsonschema # referencing -babel==2.14.0 +babel==2.15.0 # via sphinx beautifulsoup4==4.12.3 # via furo -certifi==2024.2.2 +certifi==2024.6.2 # via requests charset-normalizer==3.3.2 # via requests docutils==0.21.2 # via sphinx -furo==2024.4.27 +furo==2024.5.6 # via -r docs/requirements.in idna==3.7 # via requests imagesize==1.4.1 # via sphinx -jinja2==3.1.3 +jinja2==3.1.4 # via # sphinx # sphinx-autoapi @@ -38,31 +36,31 @@ file:.#egg=jsonschema # via -r docs/requirements.in jsonschema-specifications==2023.12.1 # via jsonschema -lxml==5.2.1 +lxml==5.2.2 # via # -r docs/requirements.in # sphinx-json-schema-spec markupsafe==2.1.5 # via jinja2 -packaging==24.0 +packaging==24.1 # via sphinx pyenchant==3.3.0rc1 # via # -r docs/requirements.in # sphinxcontrib-spelling -pygments==2.17.2 +pygments==2.18.0 # via # furo # sphinx pyyaml==6.0.1 # via sphinx-autoapi -referencing==0.35.0 +referencing==0.35.1 # via # jsonschema # jsonschema-specifications -requests==2.31.0 +requests==2.32.3 # via sphinx -rpds-py==0.18.0 +rpds-py==0.18.1 # via # jsonschema # referencing @@ -81,9 +79,9 @@ sphinx==7.3.7 # sphinx-json-schema-spec # sphinxcontrib-spelling # sphinxext-opengraph -sphinx-autoapi==3.0.0 +sphinx-autoapi==3.1.1 # via -r docs/requirements.in -sphinx-autodoc-typehints==2.1.0 +sphinx-autodoc-typehints==2.1.1 # via -r docs/requirements.in sphinx-basic-ng==1.0.0b2 # via furo @@ -107,5 +105,5 @@ sphinxcontrib-spelling==8.0.0 # via -r docs/requirements.in sphinxext-opengraph==0.9.1 # via -r docs/requirements.in -urllib3==2.2.1 +urllib3==2.2.2 # via requests diff --git a/jsonschema/_format.py b/jsonschema/_format.py index 9e4827e08..6e87620cc 100644 --- a/jsonschema/_format.py +++ b/jsonschema/_format.py @@ -413,20 +413,16 @@ def is_draft3_time(instance: object) -> bool: with suppress(ImportError): - from webcolors import CSS21_NAMES_TO_HEX import webcolors - def is_css_color_code(instance: object) -> bool: - return webcolors.normalize_hex(instance) - @_checks_drafts(draft3="color", raises=(ValueError, TypeError)) def is_css21_color(instance: object) -> bool: - if ( - not isinstance(instance, str) - or instance.lower() in CSS21_NAMES_TO_HEX - ): - return True - return is_css_color_code(instance) + if isinstance(instance, str): + try: + webcolors.name_to_hex(instance) + except ValueError: + webcolors.normalize_hex(instance.lower()) + return True with suppress(ImportError): diff --git a/jsonschema/exceptions.py b/jsonschema/exceptions.py index 82d53da6f..78da49fcd 100644 --- a/jsonschema/exceptions.py +++ b/jsonschema/exceptions.py @@ -6,7 +6,7 @@ from collections import defaultdict, deque from pprint import pformat from textwrap import dedent, indent -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar import heapq import itertools import warnings @@ -17,7 +17,9 @@ from jsonschema import _utils if TYPE_CHECKING: - from collections.abc import Iterable, Mapping, MutableMapping + from collections.abc import Iterable, Mapping, MutableMapping, Sequence + + from jsonschema import _types WEAK_MATCHES: frozenset[str] = frozenset(["anyOf", "oneOf"]) STRONG_MATCHES: frozenset[str] = frozenset() @@ -25,6 +27,13 @@ _unset = _utils.Unset() +def _pretty(thing: Any, prefix: str): + """ + Format something for an error message as prettily as we currently can. + """ + return indent(pformat(thing, width=72, sort_dicts=False), prefix).lstrip() + + def __getattr__(name): if name == "RefResolutionError": warnings.warn( @@ -44,17 +53,17 @@ class _Error(Exception): def __init__( self, message: str, - validator=_unset, - path=(), - cause=None, + validator: str = _unset, # type: ignore[assignment] + path: Iterable[str | int] = (), + cause: Exception | None = None, context=(), - validator_value=_unset, - instance=_unset, - schema=_unset, - schema_path=(), - parent=None, - type_checker=_unset, - ): + validator_value: Any = _unset, + instance: Any = _unset, + schema: Mapping[str, Any] | bool = _unset, # type: ignore[assignment] + schema_path: Iterable[str | int] = (), + parent: _Error | None = None, + type_checker: _types.TypeChecker = _unset, # type: ignore[assignment] + ) -> None: super().__init__( message, validator, @@ -82,10 +91,10 @@ def __init__( for error in context: error.parent = self - def __repr__(self): + def __repr__(self) -> str: return f"<{self.__class__.__name__}: {self.message!r}>" - def __str__(self): + def __str__(self) -> str: essential_for_verbose = ( self.validator, self.validator_value, self.instance, self.schema, ) @@ -107,19 +116,19 @@ def __str__(self): {self.message} Failed validating {self.validator!r} in {schema_path}: - {indent(pformat(self.schema, width=72), prefix).lstrip()} + {_pretty(self.schema, prefix=prefix)} On {instance_path}: - {indent(pformat(self.instance, width=72), prefix).lstrip()} + {_pretty(self.instance, prefix=prefix)} """.rstrip(), ) @classmethod - def create_from(cls, other): + def create_from(cls, other: _Error): return cls(**other._contents()) @property - def absolute_path(self): + def absolute_path(self) -> Sequence[str | int]: parent = self.parent if parent is None: return self.relative_path @@ -129,7 +138,7 @@ def absolute_path(self): return path @property - def absolute_schema_path(self): + def absolute_schema_path(self) -> Sequence[str | int]: parent = self.parent if parent is None: return self.relative_schema_path @@ -139,7 +148,7 @@ def absolute_schema_path(self): return path @property - def json_path(self): + def json_path(self) -> str: path = "$" for elem in self.absolute_path: if isinstance(elem, int): @@ -148,7 +157,11 @@ def json_path(self): path += "." + elem return path - def _set(self, type_checker=None, **kwargs): + def _set( + self, + type_checker: _types.TypeChecker | None = None, + **kwargs: Any, + ) -> None: if type_checker is not None and self._type_checker is _unset: self._type_checker = type_checker @@ -163,9 +176,10 @@ def _contents(self): ) return {attr: getattr(self, attr) for attr in attrs} - def _matches_type(self): + def _matches_type(self) -> bool: try: - expected = self.schema["type"] + # We ignore this as we want to simply crash if this happens + expected = self.schema["type"] # type: ignore[index] except (KeyError, TypeError): return False @@ -215,7 +229,7 @@ def __eq__(self, other): return NotImplemented # pragma: no cover -- uncovered but deprecated # noqa: E501 return self._cause == other._cause - def __str__(self): + def __str__(self) -> str: return str(self._cause) @@ -248,10 +262,10 @@ class UndefinedTypeCheck(Exception): A type checker was asked to check a type it did not have registered. """ - def __init__(self, type): + def __init__(self, type: str) -> None: self.type = type - def __str__(self): + def __str__(self) -> str: return f"Type {self.type!r} is unknown to this type checker" @@ -271,10 +285,10 @@ def __str__(self): return dedent( f"""\ Unknown type {self.type!r} for validator with schema: - {indent(pformat(self.schema, width=72), prefix).lstrip()} + {_pretty(self.schema, prefix=prefix)} While checking instance: - {indent(pformat(self.instance, width=72), prefix).lstrip()} + {_pretty(self.instance, prefix=prefix)} """.rstrip(), ) diff --git a/jsonschema/tests/test_exceptions.py b/jsonschema/tests/test_exceptions.py index 5b3b43621..69114e182 100644 --- a/jsonschema/tests/test_exceptions.py +++ b/jsonschema/tests/test_exceptions.py @@ -648,6 +648,29 @@ def test_uses_pprint(self): validator="maxLength", ) + def test_does_not_reorder_dicts(self): + self.assertShows( + """ + Failed validating 'type' in schema: + {'do': 3, 'not': 7, 'sort': 37, 'me': 73} + + On instance: + {'here': 73, 'too': 37, 'no': 7, 'sorting': 3} + """, + schema={ + "do": 3, + "not": 7, + "sort": 37, + "me": 73, + }, + instance={ + "here": 73, + "too": 37, + "no": 7, + "sorting": 3, + }, + ) + def test_str_works_with_instances_having_overriden_eq_operator(self): """ Check for #164 which rendered exceptions unusable when a diff --git a/noxfile.py b/noxfile.py index 2c611278b..fada3e1f8 100644 --- a/noxfile.py +++ b/noxfile.py @@ -35,13 +35,13 @@ "The Unlicense (Unlicense)", ] -SUPPORTED = ["3.8", "3.9", "3.10", "pypy3.10", "3.11", "3.12"] -LATEST = SUPPORTED[-1] +SUPPORTED = ["3.8", "3.9", "3.10", "pypy3.10", "3.11", "3.12", "3.13"] +LATEST_STABLE = "3.12" nox.options.sessions = [] -def session(default=True, python=LATEST, **kwargs): # noqa: D103 +def session(default=True, python=LATEST_STABLE, **kwargs): # noqa: D103 def _session(fn): if default: nox.options.sessions.append(kwargs.get("name", fn.__name__)) diff --git a/pyproject.toml b/pyproject.toml index 45dbc8c4f..1eea228f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: File Formats :: JSON", @@ -66,7 +67,7 @@ format-nongpl = [ "rfc3339-validator", "rfc3986-validator>0.1.0", "uri_template", - "webcolors>=1.11", + "webcolors>=24.6.0", ] [project.scripts]