Skip to content

Commit 8bd3492

Browse files
authored
Merge pull request #327 from python-jsonschema/allow-specifying-validator
Add --validator-class option for passing a validator class
2 parents 77e6f70 + f3055af commit 8bd3492

File tree

8 files changed

+381
-7
lines changed

8 files changed

+381
-7
lines changed

docs/usage.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,26 @@ not set.
251251

252252
``--base-uri`` overrides this behavior, setting a custom base URI for ``$ref``
253253
resolution.
254+
255+
``--validator-class``
256+
~~~~~~~~~~~~~~~~~~~~~
257+
258+
``check-jsonschema`` allows users to pass a custom validator class which
259+
implements the ``jsonschema.protocols.Validator`` protocol.
260+
261+
The format used for this argument is ``<module>:<class>``. For example, to
262+
explicitly use the ``jsonschema`` validator for Draft7, use
263+
``--validator-class 'jsonschema.validators:Draft7Validator'``.
264+
265+
The module containing the validator class must be importable from within the
266+
``check-jsonschema`` runtime context.
267+
268+
.. note::
269+
270+
``check-jsonschema`` will treat the validator class similarly to the
271+
``jsonschema`` library builtin validators. This includes using documented
272+
extension points like passing a format checker or the behavior enabled with
273+
``--fill-defaults``. Users of this feature are recommended to build their
274+
validators using ``jsonschema``'s documented interfaces (e.g.
275+
``jsonschema.validators.extend``) to ensure that their validators are
276+
compatible.

src/check_jsonschema/cli/main_command.py

Lines changed: 22 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

@@ -272,6 +290,7 @@ def build_schema_loader(args: ParseResult) -> SchemaLoaderBase:
272290
args.cache_filename,
273291
args.disable_cache,
274292
base_uri=args.base_uri,
293+
validator_class=args.validator_class,
275294
)
276295
else:
277296
raise NotImplementedError("no valid schema option provided")

src/check_jsonschema/cli/param_types.py

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

3+
import importlib
4+
import re
35
import typing as t
46

57
import click
8+
import jsonschema
69

710

811
class CommaDelimitedList(click.ParamType):
12+
name = "comma_delimited"
13+
914
def __init__(
1015
self,
1116
*,
@@ -43,3 +48,59 @@ def convert(
4348
)
4449

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

src/check_jsonschema/schema_loader/main.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,22 @@ def get_validator(
5656

5757

5858
class SchemaLoader(SchemaLoaderBase):
59+
validator_class: type[jsonschema.protocols.Validator] | None = None
60+
5961
def __init__(
6062
self,
6163
schemafile: str,
6264
cache_filename: str | None = None,
6365
disable_cache: bool = False,
6466
base_uri: str | None = None,
67+
validator_class: type[jsonschema.protocols.Validator] | None = None,
6568
) -> None:
6669
# record input parameters (these are not to be modified)
6770
self.schemafile = schemafile
6871
self.cache_filename = cache_filename
6972
self.disable_cache = disable_cache
7073
self.base_uri = base_uri
74+
self.validator_class = validator_class
7175

7276
# if the schema location is a URL, which may include a file:// URL, parse it
7377
self.url_info = None
@@ -132,9 +136,19 @@ def get_validator(
132136
self._parsers, retrieval_uri, schema
133137
)
134138

135-
# get the correct validator class and check the schema under its metaschema
136-
validator_cls = jsonschema.validators.validator_for(schema)
137-
validator_cls.check_schema(schema)
139+
if self.validator_class is None:
140+
# get the correct validator class and check the schema under its metaschema
141+
validator_cls = jsonschema.validators.validator_for(schema)
142+
validator_cls.check_schema(schema)
143+
else:
144+
# for a user-provided validator class, don't check_schema
145+
# on the grounds that it might *not* be valid but the user wants to use
146+
# their custom validator anyway
147+
#
148+
# in fact, there's no real guarantee that a user-provided
149+
# validator_class properly conforms to the jsonschema.Validator protocol
150+
# we *hope* that it does, but we can't be fully sure
151+
validator_cls = self.validator_class
138152

139153
# extend the validator class with default-filling behavior if appropriate
140154
if fill_defaults:

0 commit comments

Comments
 (0)