Skip to content

Add --validator-class option for passing a validator class #327

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,26 @@ not set.

``--base-uri`` overrides this behavior, setting a custom base URI for ``$ref``
resolution.

``--validator-class``
~~~~~~~~~~~~~~~~~~~~~

``check-jsonschema`` allows users to pass a custom validator class which
implements the ``jsonschema.protocols.Validator`` protocol.

The format used for this argument is ``<module>:<class>``. For example, to
explicitly use the ``jsonschema`` validator for Draft7, use
``--validator-class 'jsonschema.validators:Draft7Validator'``.

The module containing the validator class must be importable from within the
``check-jsonschema`` runtime context.

.. note::

``check-jsonschema`` will treat the validator class similarly to the
``jsonschema`` library builtin validators. This includes using documented
extension points like passing a format checker or the behavior enabled with
``--fill-defaults``. Users of this feature are recommended to build their
validators using ``jsonschema``'s documented interfaces (e.g.
``jsonschema.validators.extend``) to ensure that their validators are
compatible.
25 changes: 22 additions & 3 deletions src/check_jsonschema/cli/main_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import textwrap

import click
import jsonschema

from ..catalog import CUSTOM_SCHEMA_NAMES, SCHEMA_CATALOG
from ..checker import SchemaChecker
Expand All @@ -18,7 +19,7 @@
SchemaLoaderBase,
)
from ..transforms import TRANSFORM_LIBRARY
from .param_types import CommaDelimitedList
from .param_types import CommaDelimitedList, ValidatorClassName
from .parse_result import ParseResult, SchemaLoadingMode

BUILTIN_SCHEMA_NAMES = [f"vendor.{k}" for k in SCHEMA_CATALOG.keys()] + [
Expand Down Expand Up @@ -169,13 +170,27 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str:
)
@click.option(
"--fill-defaults",
help="Autofill 'default' values prior to validation.",
help=(
"Autofill 'default' values prior to validation. "
"This may conflict with certain third-party validators used with "
"'--validator-class'"
),
is_flag=True,
)
@click.option(
"--validator-class",
help=(
"The fully qualified name of a python validator to use in place of "
"the 'jsonschema' library validators, in the form of '<package>:<class>'. "
"The validator must be importable in the same environment where "
"'check-jsonschema' is run."
),
type=ValidatorClassName(),
)
@click.option(
"-o",
"--output-format",
help="Which output format to use",
help="Which output format to use.",
type=click.Choice(tuple(REPORTER_BY_NAME.keys()), case_sensitive=False),
default="text",
)
Expand Down Expand Up @@ -217,6 +232,7 @@ def main(
traceback_mode: str,
data_transform: str | None,
fill_defaults: bool,
validator_class: type[jsonschema.protocols.Validator] | None,
output_format: str,
verbose: int,
quiet: int,
Expand All @@ -225,6 +241,8 @@ def main(
args = ParseResult()

args.set_schema(schemafile, builtin_schema, check_metaschema)
args.set_validator(validator_class)

args.base_uri = base_uri
args.instancefiles = instancefiles

Expand Down Expand Up @@ -272,6 +290,7 @@ def build_schema_loader(args: ParseResult) -> SchemaLoaderBase:
args.cache_filename,
args.disable_cache,
base_uri=args.base_uri,
validator_class=args.validator_class,
)
else:
raise NotImplementedError("no valid schema option provided")
Expand Down
61 changes: 61 additions & 0 deletions src/check_jsonschema/cli/param_types.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from __future__ import annotations

import importlib
import re
import typing as t

import click
import jsonschema


class CommaDelimitedList(click.ParamType):
name = "comma_delimited"

def __init__(
self,
*,
Expand Down Expand Up @@ -43,3 +48,59 @@ def convert(
)

return resolved


class ValidatorClassName(click.ParamType):
name = "validator"

def convert(
self, value: str, param: click.Parameter | None, ctx: click.Context | None
) -> type[jsonschema.protocols.Validator]:
"""
Use a colon-based parse to split this up and do the import with importlib.
This method is inspired by pkgutil.resolve_name and uses the newer syntax
documented there.

pkgutil supports both
W(.W)*
and
W(.W)*:(W(.W)*)?
as patterns, but notes that the first one is for backwards compatibility only.
The second form is preferred because it clarifies the division between the
importable name and any namespaced path to an object or class.

As a result, only one import is needed, rather than iterative imports over the
list of names.
"""
value = super().convert(value, param, ctx)
pattern = re.compile(
r"^(?P<pkg>(?!\d)(\w+)(\.(?!\d)(\w+))*):"
r"(?P<cls>(?!\d)(\w+)(\.(?!\d)(\w+))*)$"
)
m = pattern.match(value)
if m is None:
self.fail(
f"'{value}' is not a valid specifier in '<package>:<class>' form",
param,
ctx,
)
pkg = m.group("pkg")
classname = m.group("cls")
try:
result: t.Any = importlib.import_module(pkg)
except ImportError as e:
self.fail(f"'{pkg}' was not an importable module. {str(e)}", param, ctx)
try:
for part in classname.split("."):
result = getattr(result, part)
except AttributeError as e:
self.fail(
f"'{classname}' was not resolvable to a class in '{pkg}'. {str(e)}",
param,
ctx,
)

if not isinstance(result, type):
self.fail(f"'{classname}' in '{pkg}' is not a class", param, ctx)

return t.cast(t.Type[jsonschema.protocols.Validator], result)
15 changes: 14 additions & 1 deletion src/check_jsonschema/cli/parse_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import enum

import click
import jsonschema

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

def set_validator(
self, validator_class: type[jsonschema.protocols.Validator] | None
) -> None:
if validator_class is None:
return
if self.schema_mode != SchemaLoadingMode.filepath:
raise click.UsageError(
"--validator-class can only be used with --schemafile for schema loading"
)
self.validator_class = validator_class

@property
def format_opts(self) -> FormatOptions:
return FormatOptions(
Expand Down
20 changes: 17 additions & 3 deletions src/check_jsonschema/schema_loader/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,22 @@ def get_validator(


class SchemaLoader(SchemaLoaderBase):
validator_class: type[jsonschema.protocols.Validator] | None = None

def __init__(
self,
schemafile: str,
cache_filename: str | None = None,
disable_cache: bool = False,
base_uri: str | None = None,
validator_class: type[jsonschema.protocols.Validator] | None = None,
) -> None:
# record input parameters (these are not to be modified)
self.schemafile = schemafile
self.cache_filename = cache_filename
self.disable_cache = disable_cache
self.base_uri = base_uri
self.validator_class = validator_class

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

# get the correct validator class and check the schema under its metaschema
validator_cls = jsonschema.validators.validator_for(schema)
validator_cls.check_schema(schema)
if self.validator_class is None:
# get the correct validator class and check the schema under its metaschema
validator_cls = jsonschema.validators.validator_for(schema)
validator_cls.check_schema(schema)
else:
# for a user-provided validator class, don't check_schema
# on the grounds that it might *not* be valid but the user wants to use
# their custom validator anyway
#
# in fact, there's no real guarantee that a user-provided
# validator_class properly conforms to the jsonschema.Validator protocol
# we *hope* that it does, but we can't be fully sure
validator_cls = self.validator_class

# extend the validator class with default-filling behavior if appropriate
if fill_defaults:
Expand Down
Loading