Skip to content

Commit db1bf62

Browse files
committed
Add interface for passing custom validators
This adds the necessary pieces for passing a custom validator class to check-jsonschema. `--fill-defaults` help text contains a hint that it may conflict (depending on the validator), to help warn of the potential for surprising interplay between a custom validator class and optional check-jsonschema behaviors. This commit only adds the interface pieces for passing the validator class to the CLI in a well-specified format, and handles the import/load of that value. No implementation in terms of threading the resulting value down to the validator(s) is included yet.
1 parent 29c0e44 commit db1bf62

File tree

4 files changed

+226
-4
lines changed

4 files changed

+226
-4
lines changed

src/check_jsonschema/cli/main_command.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import textwrap
55

66
import click
7+
import jsonschema
78

89
from ..catalog import CUSTOM_SCHEMA_NAMES, SCHEMA_CATALOG
910
from ..checker import SchemaChecker
@@ -18,7 +19,7 @@
1819
SchemaLoaderBase,
1920
)
2021
from ..transforms import TRANSFORM_LIBRARY
21-
from .param_types import CommaDelimitedList
22+
from .param_types import CommaDelimitedList, ValidatorClassName
2223
from .parse_result import ParseResult, SchemaLoadingMode
2324

2425
BUILTIN_SCHEMA_NAMES = [f"vendor.{k}" for k in SCHEMA_CATALOG.keys()] + [
@@ -169,13 +170,27 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str:
169170
)
170171
@click.option(
171172
"--fill-defaults",
172-
help="Autofill 'default' values prior to validation.",
173+
help=(
174+
"Autofill 'default' values prior to validation. "
175+
"This may conflict with certain third-party validators used with "
176+
"'--validator-class'"
177+
),
173178
is_flag=True,
174179
)
180+
@click.option(
181+
"--validator-class",
182+
help=(
183+
"The fully qualified name of a python validator to use in place of "
184+
"the 'jsonschema' library validators, in the form of '<package>:<class>'. "
185+
"The validator must be importable in the same environment where "
186+
"'check-jsonschema' is run."
187+
),
188+
type=ValidatorClassName(),
189+
)
175190
@click.option(
176191
"-o",
177192
"--output-format",
178-
help="Which output format to use",
193+
help="Which output format to use.",
179194
type=click.Choice(tuple(REPORTER_BY_NAME.keys()), case_sensitive=False),
180195
default="text",
181196
)
@@ -217,6 +232,7 @@ def main(
217232
traceback_mode: str,
218233
data_transform: str | None,
219234
fill_defaults: bool,
235+
validator_class: type[jsonschema.protocols.Validator] | None,
220236
output_format: str,
221237
verbose: int,
222238
quiet: int,
@@ -225,6 +241,8 @@ def main(
225241
args = ParseResult()
226242

227243
args.set_schema(schemafile, builtin_schema, check_metaschema)
244+
args.set_validator(validator_class)
245+
228246
args.base_uri = base_uri
229247
args.instancefiles = instancefiles
230248

src/check_jsonschema/cli/param_types.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
from __future__ import annotations
22

3+
import importlib
4+
import re
35
import typing as t
6+
import warnings
47

58
import click
9+
import jsonschema
610

711

812
class CommaDelimitedList(click.ParamType):
13+
name = "comma_delimited"
14+
915
def __init__(
1016
self,
1117
*,
@@ -43,3 +49,69 @@ def convert(
4349
)
4450

4551
return resolved
52+
53+
54+
class ValidatorClassName(click.ParamType):
55+
name = "validator"
56+
57+
def convert(
58+
self, value: str, param: click.Parameter | None, ctx: click.Context | None
59+
) -> type[jsonschema.protocols.Validator]:
60+
"""
61+
Use a colon-based parse to split this up and do the import with importlib.
62+
This method is inspired by pkgutil.resolve_name and uses the newer syntax
63+
documented there.
64+
65+
pkgutil supports both
66+
W(.W)*
67+
and
68+
W(.W)*:(W(.W)*)?
69+
as patterns, but notes that the first one is for backwards compatibility only.
70+
The second form is preferred because it clarifies the division between the
71+
importable name and any namespaced path to an object or class.
72+
73+
As a result, only one import is needed, rather than iterative imports over the
74+
list of names.
75+
"""
76+
value = super().convert(value, param, ctx)
77+
pattern = re.compile(
78+
r"^(?P<pkg>(?!\d)(\w+)(\.(?!\d)(\w+))*):"
79+
r"(?P<cls>(?!\d)(\w+)(\.(?!\d)(\w+))*)$"
80+
)
81+
m = pattern.match(value)
82+
if m is None:
83+
self.fail(
84+
f"'{value}' is not a valid specifier in '<package>:<class>' form",
85+
param,
86+
ctx,
87+
)
88+
pkg = m.group("pkg")
89+
classname = m.group("cls")
90+
try:
91+
result: t.Any = importlib.import_module(pkg)
92+
except ImportError as e:
93+
self.fail(f"'{pkg}' was not an importable module. {str(e)}", param, ctx)
94+
try:
95+
for part in classname.split("."):
96+
result = getattr(result, part)
97+
except AttributeError as e:
98+
self.fail(
99+
f"'{classname}' was not resolvable to a class in '{pkg}'. {str(e)}",
100+
param,
101+
ctx,
102+
)
103+
104+
if not callable(result):
105+
self.fail(
106+
f"'{classname}' in '{pkg}' is not a class or callable", param, ctx
107+
)
108+
109+
if not isinstance(result, type):
110+
warnings.warn(
111+
f"'{classname}' in '{pkg}' is not a class. If it is a function "
112+
f"returning a Validator, it still might work, but this usage "
113+
"is not recommended.",
114+
stacklevel=1,
115+
)
116+
117+
return t.cast(t.Type[jsonschema.protocols.Validator], result)

src/check_jsonschema/cli/parse_result.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import enum
44

55
import click
6+
import jsonschema
67

78
from ..formats import FormatOptions, RegexVariantName
89
from ..transforms import Transform
@@ -28,7 +29,8 @@ def __init__(self) -> None:
2829
self.default_filetype: str = "json"
2930
# data-transform (for Azure Pipelines and potentially future transforms)
3031
self.data_transform: Transform | None = None
31-
# fill default values on instances during validation
32+
# validation behavioral controls
33+
self.validator_class: type[jsonschema.protocols.Validator] | None = None
3234
self.fill_defaults: bool = False
3335
# regex format options
3436
self.disable_all_formats: bool = False
@@ -65,6 +67,17 @@ def set_schema(
6567
else:
6668
self.schema_mode = SchemaLoadingMode.metaschema
6769

70+
def set_validator(
71+
self, validator_class: type[jsonschema.protocols.Validator] | None
72+
) -> None:
73+
if validator_class is None:
74+
return
75+
if self.schema_mode != SchemaLoadingMode.filepath:
76+
raise click.UsageError(
77+
"--validator-class can only be used with --schemafile for schema loading"
78+
)
79+
self.validator_class = validator_class
80+
6881
@property
6982
def format_opts(self) -> FormatOptions:
7083
return FormatOptions(

tests/unit/test_cli_parse.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import pathlib
4+
import sys
35
from unittest import mock
46

57
import click
@@ -27,6 +29,33 @@ def mock_parse_result():
2729
yield args
2830

2931

32+
@pytest.fixture
33+
def mock_module(tmp_path, monkeypatch):
34+
monkeypatch.syspath_prepend(tmp_path)
35+
all_names_to_clear = []
36+
37+
def func(path, text):
38+
path = pathlib.Path(path)
39+
mod_dir = tmp_path / (path.parent)
40+
mod_dir.mkdir(parents=True, exist_ok=True)
41+
for part in path.parts[:-1]:
42+
(tmp_path / part / "__init__.py").touch()
43+
44+
(tmp_path / path).write_text(text)
45+
46+
for i in range(len(path.parts)):
47+
modname = ".".join(path.parts[: i + 1])
48+
if modname.endswith(".py"):
49+
modname = modname[:-3]
50+
all_names_to_clear.append(modname)
51+
52+
yield func
53+
54+
for name in all_names_to_clear:
55+
if name in sys.modules:
56+
del sys.modules[name]
57+
58+
3059
@pytest.fixture(autouse=True)
3160
def mock_cli_exec(boxed_context):
3261
def get_ctx(*args):
@@ -258,3 +287,93 @@ def test_disable_all_formats(runner, mock_parse_result, addargs):
258287
+ addargs,
259288
)
260289
assert mock_parse_result.disable_all_formats is True
290+
291+
292+
def test_can_specify_custom_validator_class(runner, mock_parse_result, mock_module):
293+
mock_module("foo.py", "class MyValidator: pass")
294+
import foo
295+
296+
result = runner.invoke(
297+
cli_main,
298+
[
299+
"--schemafile",
300+
"schema.json",
301+
"foo.json",
302+
"--validator-class",
303+
"foo:MyValidator",
304+
],
305+
)
306+
assert result.exit_code == 0
307+
assert mock_parse_result.validator_class == foo.MyValidator
308+
309+
310+
def test_warns_on_validator_function(runner, mock_parse_result, mock_module):
311+
mock_module(
312+
"foo/bar.py",
313+
"""\
314+
class MyValidator: pass
315+
316+
def validator(*args, **kwargs):
317+
return MyValidator(*args, **kwargs)
318+
""",
319+
)
320+
import foo.bar
321+
322+
with pytest.warns(UserWarning, match="'validator' in 'foo.bar' is not a class"):
323+
result = runner.invoke(
324+
cli_main,
325+
[
326+
"--schemafile",
327+
"schema.json",
328+
"foo.json",
329+
"--validator-class",
330+
"foo.bar:validator",
331+
],
332+
)
333+
assert result.exit_code == 0
334+
assert mock_parse_result.validator_class == foo.bar.validator
335+
336+
337+
@pytest.mark.parametrize("failmode", ("syntax", "import", "attr", "callability"))
338+
def test_can_custom_validator_class_fails(
339+
runner, mock_parse_result, mock_module, failmode
340+
):
341+
mock_module(
342+
"foo.py",
343+
"""\
344+
class MyValidator: pass
345+
346+
def validator(*args, **kwargs):
347+
return MyValidator(*args, **kwargs)
348+
349+
other_thing = 100
350+
""",
351+
)
352+
353+
if failmode == "syntax":
354+
arg = "foo.MyValidator"
355+
elif failmode == "import":
356+
arg = "foo.bar:MyValidator"
357+
elif failmode == "attr":
358+
arg = "foo:no_such_attr"
359+
elif failmode == "callability":
360+
arg = "foo:other_thing"
361+
else:
362+
raise NotImplementedError
363+
364+
result = runner.invoke(
365+
cli_main,
366+
["--schemafile", "schema.json", "foo.json", "--validator-class", arg],
367+
)
368+
assert result.exit_code == 2
369+
370+
if failmode == "syntax":
371+
assert "is not a valid specifier" in result.stderr
372+
elif failmode == "import":
373+
assert "was not an importable module" in result.stderr
374+
elif failmode == "attr":
375+
assert "was not resolvable to a class" in result.stderr
376+
elif failmode == "callability":
377+
assert "is not a class or callable" in result.stderr
378+
else:
379+
raise NotImplementedError

0 commit comments

Comments
 (0)