Skip to content

Commit eb42b36

Browse files
committed
Update to jsonschema>=4.18 and 'referencing'
There's a new implementation of ref resolution in newer versions of 'jsonschema'. Upgrade to use this, plus add support for loading YAML, JSON5, and TOML schemas as references (so long as they have the filetype suffixes which make them easily detectable).
1 parent 96a8ea1 commit eb42b36

File tree

10 files changed

+116
-70
lines changed

10 files changed

+116
-70
lines changed

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ python_requires = >=3.7
2020
install_requires =
2121
importlib-resources>=1.4.0;python_version<"3.9"
2222
ruamel.yaml==0.17.32
23-
jsonschema>=4.5.1,<5.0
23+
jsonschema>=4.18.0,<5.0
2424
requests<3.0
2525
click>=8,<9
2626
package_dir=

src/check_jsonschema/checker.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import click
77
import jsonschema
8+
import referencing.exceptions
89

910
from . import utils
1011
from .formats import FormatOptions
@@ -75,7 +76,11 @@ def _build_result(self) -> CheckResult:
7576
def _run(self) -> None:
7677
try:
7778
result = self._build_result()
78-
except jsonschema.RefResolutionError as e:
79+
except (
80+
referencing.exceptions.NoSuchResource,
81+
referencing.exceptions.Unretrievable,
82+
referencing.exceptions.Unresolvable,
83+
) as e:
7984
self._fail("Failure resolving $ref within schema\n", e)
8085

8186
self._reporter.report_result(result)

src/check_jsonschema/identify_filetype.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818
}
1919

2020

21-
def path_to_type(path: pathlib.Path, *, default_type: str = "json") -> str:
22-
ext = path.suffix.lstrip(".")
21+
def path_to_type(path: str | pathlib.Path, *, default_type: str = "json") -> str:
22+
if isinstance(path, str):
23+
ext = path.rpartition(".")[2]
24+
else:
25+
ext = path.suffix.lstrip(".")
2326

2427
if ext in _EXTENSION_MAP:
2528
return _EXTENSION_MAP[ext]

src/check_jsonschema/parsers/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,15 @@ def get(
8282
+ ",".join(self._by_tag.keys())
8383
)
8484

85-
def parse_file(self, path: pathlib.Path, default_filetype: str) -> t.Any:
85+
def parse_data_with_path(
86+
self, data: t.BinaryIO, path: pathlib.Path | str, default_filetype: str
87+
) -> t.Any:
8688
loadfunc = self.get(path, default_filetype)
8789
try:
88-
with open(path, "rb") as fp:
89-
return loadfunc(fp)
90+
return loadfunc(data)
9091
except LOADING_FAILURE_ERROR_TYPES as e:
9192
raise FailedFileLoadError(f"Failed to parse {path}") from e
93+
94+
def parse_file(self, path: pathlib.Path | str, default_filetype: str) -> t.Any:
95+
with open(path, "rb") as fp:
96+
return self.parse_data_with_path(fp, path, default_filetype)

src/check_jsonschema/schema_loader/main.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99

1010
from ..builtin_schemas import get_builtin_schema
1111
from ..formats import FormatOptions, make_format_checker
12+
from ..parsers import ParserSet
1213
from ..utils import is_url_ish
1314
from .errors import UnsupportedUrlScheme
1415
from .readers import HttpSchemaReader, LocalSchemaReader
15-
from .resolver import make_ref_resolver
16+
from .resolver import make_reference_registry
1617

1718

1819
def _extend_with_default(
@@ -71,6 +72,9 @@ def __init__(
7172
if is_url_ish(self.schemafile):
7273
self.url_info = urllib.parse.urlparse(self.schemafile)
7374

75+
# setup a parser collection
76+
self._parsers = ParserSet()
77+
7478
# setup a schema reader lazily, when needed
7579
self._reader: LocalSchemaReader | HttpSchemaReader | None = None
7680

@@ -117,11 +121,9 @@ def get_validator(
117121
# format checker (which may be None)
118122
format_checker = make_format_checker(format_opts, schema_dialect)
119123

120-
# ref resolver which may be built from the schema path
121-
# if the location is a URL, there's no change, but if it's a file path
122-
# it's made absolute and URI-ized
123-
# the resolver should use `$id` if there is one present in the schema
124-
ref_resolver = make_ref_resolver(schema_uri, schema)
124+
# reference resolution
125+
# with support for YAML, TOML, and other formats from the parsers
126+
reference_registry = make_reference_registry(self._parsers, schema_uri, schema)
125127

126128
# get the correct validator class and check the schema under its metaschema
127129
validator_cls = jsonschema.validators.validator_for(schema)
@@ -134,7 +136,7 @@ def get_validator(
134136
# now that we know it's safe to try to create the validator instance, do it
135137
validator = validator_cls(
136138
schema,
137-
resolver=ref_resolver,
139+
registry=reference_registry,
138140
format_checker=format_checker,
139141
)
140142
return t.cast(jsonschema.Validator, validator)
@@ -143,6 +145,7 @@ def get_validator(
143145
class BuiltinSchemaLoader(SchemaLoader):
144146
def __init__(self, schema_name: str) -> None:
145147
self.schema_name = schema_name
148+
self._parsers = ParserSet()
146149

147150
def get_schema_ref_base(self) -> str | None:
148151
return None

src/check_jsonschema/schema_loader/readers.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,10 @@ def _run_load_callback(schema_location: str, callback: t.Callable) -> dict:
2525

2626

2727
class LocalSchemaReader:
28-
FORMATS = ("json", "json5", "yaml")
29-
3028
def __init__(self, filename: str) -> None:
3129
self.path = filename2path(filename)
3230
self.filename = str(self.path)
33-
self.parsers = ParserSet(supported_formats=self.FORMATS)
31+
self.parsers = ParserSet()
3432

3533
def get_ref_base(self) -> str:
3634
return self.path.as_uri()
Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,63 @@
11
from __future__ import annotations
22

3+
import pathlib
34
import typing as t
4-
5-
import click
6-
import jsonschema
7-
8-
9-
class _CliRefResolver(jsonschema.RefResolver):
10-
def resolve_remote(self, uri: str) -> t.Any:
11-
if uri.endswith(".yaml") or uri.endswith(".yml"):
12-
click.secho(
13-
"""\
14-
WARNING: You appear to be using a schema which references a YAML file.
15-
16-
This is not supported by check-jsonschema and may result in errors.
17-
""",
18-
err=True,
19-
fg="yellow",
20-
)
21-
elif uri.endswith(".json5"):
22-
click.secho(
23-
"""\
24-
WARNING: You appear to be using a schema which references a JSON5 file.
25-
26-
This is not supported by check-jsonschema and may result in errors.
27-
""",
28-
err=True,
29-
fg="yellow",
30-
)
31-
return super().resolve_remote(uri)
32-
33-
34-
def make_ref_resolver(
35-
schema_uri: str | None, schema: dict
36-
) -> jsonschema.RefResolver | None:
37-
if not schema_uri:
38-
return None
39-
40-
base_uri = schema.get("$id", schema_uri)
41-
# FIXME: temporary type-ignore because typeshed has the type wrong
42-
return _CliRefResolver(base_uri, schema) # type: ignore[arg-type]
5+
import urllib.parse
6+
7+
import referencing
8+
import requests
9+
from referencing.jsonschema import DRAFT202012
10+
11+
from ..parsers import ParserSet
12+
from ..utils import filename2path
13+
14+
15+
def make_reference_registry(
16+
parsers: ParserSet, schema_uri: str | None, schema: dict
17+
) -> referencing.Registry:
18+
schema_resource = referencing.Resource.from_contents(
19+
schema, default_specification=DRAFT202012
20+
)
21+
registry = referencing.Registry(
22+
retrieve=create_retrieve_callable(parsers, schema_uri)
23+
)
24+
25+
if schema_uri is not None:
26+
registry = registry.with_resource(uri=schema_uri, resource=schema_resource)
27+
28+
id_attribute = schema.get("$id")
29+
if id_attribute is not None:
30+
registry = registry.with_resource(uri=id_attribute, resource=schema_resource)
31+
32+
return registry
33+
34+
35+
def create_retrieve_callable(
36+
parser_set: ParserSet, schema_uri: str | None
37+
) -> t.Callable[[str], referencing.Resource]:
38+
def get_local_file(uri: str):
39+
path = pathlib.Path(uri)
40+
if not path.is_absolute():
41+
if schema_uri is None:
42+
raise referencing.exceptions.Unretrievable(
43+
f"Cannot retrieve schema reference data for '{uri}' from "
44+
"local filesystem. "
45+
"The path appears relative, but there is no known local base path."
46+
)
47+
schema_path = filename2path(schema_uri)
48+
path = schema_path.parent / path
49+
return parser_set.parse_file(path, "json")
50+
51+
def retrieve_reference(uri: str) -> referencing.Resource:
52+
scheme = urllib.parse.urlsplit(uri).scheme
53+
if scheme in ("http", "https"):
54+
data = requests.get(uri, stream=True)
55+
parsed_object = parser_set.parse_data_with_path(data.raw, uri, "json")
56+
else:
57+
parsed_object = get_local_file(uri)
58+
59+
return referencing.Resource.from_contents(
60+
parsed_object, default_specification=DRAFT202012
61+
)
62+
63+
return retrieve_reference

tests/acceptance/conftest.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1+
import textwrap
2+
13
import pytest
24
from click.testing import CliRunner
35

46
from check_jsonschema import main as cli_main
57

68

9+
def _render_result(result):
10+
return f"""
11+
output:
12+
{textwrap.indent(result.output, " ")}
13+
14+
stderr:
15+
{textwrap.indent(result.stderr, " ")}
16+
"""
17+
18+
719
@pytest.fixture
820
def cli_runner():
921
return CliRunner(mix_stderr=False)
@@ -22,8 +34,14 @@ def func(cli_args, *args, **kwargs):
2234

2335
@pytest.fixture
2436
def run_line_simple(run_line):
25-
def func(cli_args, *args, **kwargs):
26-
res = run_line(["check-jsonschema"] + cli_args, *args, **kwargs)
27-
assert res.exit_code == 0
37+
def func(cli_args, *args, full_traceback: bool = True, **kwargs):
38+
res = run_line(
39+
["check-jsonschema"]
40+
+ (["--traceback-mode", "full"] if full_traceback else [])
41+
+ cli_args,
42+
*args,
43+
**kwargs,
44+
)
45+
assert res.exit_code == 0, _render_result(res)
2846

2947
return func

tests/acceptance/test_nonjson_schema_handling.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@
3131

3232

3333
@pytest.mark.parametrize("passing_data", [True, False])
34-
def test_warning_on_yaml_reference_passes(run_line, tmp_path, passing_data):
34+
def test_yaml_reference(run_line, tmp_path, passing_data):
3535
main_schemafile = tmp_path / "main_schema.json"
3636
main_schemafile.write_text(json.dumps(YAML_REF_MAIN_SCHEMA))
37+
# JSON is a subset of YAML, so this works for generated YAML
3738
ref_schema = tmp_path / "title_schema.yaml"
3839
ref_schema.write_text(json.dumps(TITLE_SCHEMA))
3940

@@ -47,14 +48,11 @@ def test_warning_on_yaml_reference_passes(run_line, tmp_path, passing_data):
4748
["check-jsonschema", "--schemafile", str(main_schemafile), str(doc)]
4849
)
4950
assert result.exit_code == (0 if passing_data else 1)
50-
assert (
51-
"WARNING: You appear to be using a schema which references a YAML file"
52-
in result.stderr
53-
)
5451

5552

53+
@pytest.mark.skipif(not JSON5_ENABLED, reason="test requires json5")
5654
@pytest.mark.parametrize("passing_data", [True, False])
57-
def test_warning_on_json5_reference(run_line, tmp_path, passing_data):
55+
def test_json5_reference(run_line, tmp_path, passing_data):
5856
main_schemafile = tmp_path / "main_schema.json"
5957
main_schemafile.write_text(json.dumps(JSON5_REF_MAIN_SCHEMA))
6058
ref_schema = tmp_path / "title_schema.json5"
@@ -70,10 +68,6 @@ def test_warning_on_json5_reference(run_line, tmp_path, passing_data):
7068
["check-jsonschema", "--schemafile", str(main_schemafile), str(doc)]
7169
)
7270
assert result.exit_code == (0 if passing_data else 1)
73-
assert (
74-
"WARNING: You appear to be using a schema which references a JSON5 file"
75-
in result.stderr
76-
)
7771

7872

7973
@pytest.mark.skipif(not JSON5_ENABLED, reason="test requires json5")

tox.ini

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,3 @@ commands = python ./scripts/generate-hooks-config.py
8787
[pytest]
8888
filterwarnings =
8989
error
90-
ignore:jsonschema\.(RefResolver|exceptions\.RefResolutionError) is deprecated as of (version |v)4\.18\.0:DeprecationWarning

0 commit comments

Comments
 (0)