Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dist/
.coverage.*
.tox
.vscode
.idea
7 changes: 7 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
# we need types_or support
minimum_pre_commit_version: 2.9.0

- id: check-yaml-schema-modelines
name: Validate YAML files with schema modelines
description: 'Validate YAML files against schemas declared in YAML modeline comments'
entry: check-jsonschema --schema-from-modeline
language: python
types: [yaml]

# --AUTOGEN_HOOKS_START-- #

# this hook is autogenerated from a script
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ Then run, as in

check-jsonschema --schemafile schema.json instance.json

YAML files can also declare their schema with a modeline comment and be checked
without repeating schema paths in the command:

check-jsonschema --schema-from-modeline config/*.yaml

## Documentation

Full documentation can be found at https://check-jsonschema.readthedocs.io/
19 changes: 19 additions & 0 deletions docs/precommit_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ You must specify a schema using pre-commit ``args`` configuration.
files: ^data/.*\.json$
args: ["--schemafile", "schemas/foo.json"]

The ``check-jsonschema`` hook can also validate YAML files against schemas
declared in YAML modeline comments. Files without a modeline are skipped,
which lets one hook cover YAML files that use different schemas.

.. code-block:: yaml
:caption: example config

- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.37.2
hooks:
- id: check-yaml-schema-modelines

Supported modeline examples:

.. code-block:: yaml

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# $schema: ../schemas/service.json


``check-metaschema``
~~~~~~~~~~~~~~~~~~~~
Expand Down
7 changes: 7 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Detailed helptext is always available interactively via
- Description
* - ``--schemafile``
- The path or URL for a file containing a schema to use.
* - ``--schema-from-modeline``
- Validate YAML files using schemas declared in YAML modeline comments.
Files without a modeline are skipped.
* - ``-v``, ``--verbose``
- Request more output.
* - ``-q``, ``--quiet``
Expand Down Expand Up @@ -77,6 +80,10 @@ These options are mutually exclusive, so exactly one must be used.
* - ``--check-metaschema``
- Validate each instancefile as a JSON Schema, using the relevant metaschema
defined in ``"$schema"``.
* - ``--schema-from-modeline``
- Validate YAML files using the schema declared in a modeline comment such as
``# yaml-language-server: $schema=../schemas/foo.json``. Relative schema
paths are resolved relative to the YAML file.

``--builtin-schema`` Choices
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
80 changes: 68 additions & 12 deletions src/check_jsonschema/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@

from . import format_errors
from .formats import FormatOptions
from .instance_loader import InstanceLoader
from .parsers import ParseError
from .instance_loader import InstanceLoader, InstanceParseError
from .regex_variants import RegexImplementation
from .reporter import Reporter
from .result import CheckResult
Expand Down Expand Up @@ -50,12 +49,29 @@ def _fail(self, msg: str, err: Exception | None = None) -> t.NoReturn:
format_errors.print_error(err, mode=self._traceback_mode)
raise _Exit(1)

def _fail_ref_resolution(self, err: Exception) -> t.NoReturn:
click.echo("Failure resolving $ref within schema", err=True)
if self._traceback_mode == "full":
format_errors.print_error(err, mode=self._traceback_mode)
else:
click.echo(f" {_format_ref_resolution_error(err)}", err=True)
raise _Exit(1)

def get_validator(
self, path: pathlib.Path | str, doc: dict[str, t.Any]
self,
path: pathlib.Path | str,
doc: t.Any,
*,
schemafile: str | None = None,
) -> jsonschema.protocols.Validator:
try:
return self._schema_loader.get_validator(
path, doc, self._format_opts, self._regex_impl, self._fill_defaults
path,
doc,
self._format_opts,
self._regex_impl,
self._fill_defaults,
schemafile=schemafile,
)
except SchemaParseError as e:
self._fail("Error: schemafile could not be parsed as JSON", e)
Expand All @@ -68,17 +84,32 @@ def get_validator(

def _build_result(self) -> CheckResult:
result = CheckResult()
for path, data in self._instance_loader.iter_files():
if isinstance(data, ParseError):
result.record_parse_error(path, data)
for instance in self._instance_loader.iter_documents():
if isinstance(instance, InstanceParseError):
result.record_parse_error(instance.filename, instance.error)
else:
validator = self.get_validator(path, data)
validator = self.get_validator(
instance.filename,
instance.data,
schemafile=instance.schemafile,
)
passing = True
for err in validator.iter_errors(data):
result.record_validation_error(path, err)
try:
validation_errors = validator.iter_errors(instance.data)
for err in validation_errors:
result.record_validation_error(instance.label, err)
passing = False
except (
referencing.exceptions.NoSuchResource,
referencing.exceptions.Unretrievable,
referencing.exceptions.Unresolvable,
) as err:
result.record_validation_error(
instance.label, _make_ref_resolution_error(err)
)
passing = False
if passing:
result.record_validation_success(path)
result.record_validation_success(instance.label)
return result

def _run(self) -> None:
Expand All @@ -89,7 +120,7 @@ def _run(self) -> None:
referencing.exceptions.Unretrievable,
referencing.exceptions.Unresolvable,
) as e:
self._fail("Failure resolving $ref within schema\n", e)
self._fail_ref_resolution(e)

self._reporter.report_result(result)
if not result.success:
Expand All @@ -101,3 +132,28 @@ def run(self) -> int:
except _Exit as e:
return e.code
return 0


def _make_ref_resolution_error(err: Exception) -> jsonschema.ValidationError:
return jsonschema.ValidationError(
f"A $ref in the schema could not be resolved: "
f"{_format_ref_resolution_error(err)}"
)


def _format_ref_resolution_error(err: Exception) -> str:
cause = err.__cause__ or err.__context__ or err
if isinstance(cause, referencing.exceptions.PointerToNowhere):
return (
f"{type(cause).__name__}: {cause.ref!r} does not exist within "
"the loaded schema."
)
if isinstance(cause, referencing.exceptions.NoSuchResource):
return f"{type(cause).__name__}: could not retrieve {cause.ref!r}."
if isinstance(cause, referencing.exceptions.Unretrievable):
return f"{type(cause).__name__}: could not retrieve {cause.ref!r}."
if isinstance(cause, referencing.exceptions.Unresolvable):
ref = getattr(cause, "ref", None)
if ref is not None:
return f"{type(cause).__name__}: could not resolve {ref!r}."
return format_errors.format_error_message(cause)
22 changes: 20 additions & 2 deletions src/check_jsonschema/cli/main_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from ..schema_loader import (
BuiltinSchemaLoader,
MetaSchemaLoader,
ModelineSchemaLoader,
SchemaLoader,
SchemaLoaderBase,
)
Expand Down Expand Up @@ -62,7 +63,8 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str:
help="""\
Check JSON and YAML files against a JSON Schema.

The schema is specified either with '--schemafile' or with '--builtin-schema'.
The schema is specified with '--schemafile', '--builtin-schema', or
'--schema-from-modeline'.

'check-jsonschema' supports format checks with appropriate libraries installed,
including the following formats by default:
Expand Down Expand Up @@ -112,6 +114,14 @@ def pretty_helptext_list(values: list[str] | tuple[str, ...]) -> str:
type=click.Choice(BUILTIN_SCHEMA_CHOICES, case_sensitive=False),
metavar="BUILTIN_SCHEMA_NAME",
)
@click.option(
"--schema-from-modeline",
is_flag=True,
help=(
"Validate YAML files using the schema declared in a YAML modeline "
"comment. Files without a schema modeline are skipped."
),
)
@click.option(
"--check-metaschema",
is_flag=True,
Expand Down Expand Up @@ -240,6 +250,7 @@ def main(
schemafile: str | None,
builtin_schema: str | None,
base_uri: str | None,
schema_from_modeline: bool,
check_metaschema: bool,
no_cache: bool,
cache_filename: str | None,
Expand All @@ -261,7 +272,7 @@ def main(

args.set_regex_variant(regex_variant, legacy_opt=format_regex)

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

args.base_uri = base_uri
Expand Down Expand Up @@ -299,6 +310,12 @@ def main(
def build_schema_loader(args: ParseResult) -> SchemaLoaderBase:
if args.schema_mode == SchemaLoadingMode.metaschema:
return MetaSchemaLoader(base_uri=args.base_uri)
elif args.schema_mode == SchemaLoadingMode.modeline:
if args.base_uri is not None:
raise click.UsageError(
"--base-uri cannot be used with --schema-from-modeline"
)
return ModelineSchemaLoader(disable_cache=args.disable_cache)
elif args.schema_mode == SchemaLoadingMode.builtin:
assert args.schema_path is not None
return BuiltinSchemaLoader(args.schema_path, base_uri=args.base_uri)
Expand All @@ -320,6 +337,7 @@ def build_instance_loader(args: ParseResult) -> InstanceLoader:
default_filetype=args.default_filetype,
force_filetype=args.force_filetype,
data_transform=args.data_transform,
schema_from_modeline=args.schema_mode == SchemaLoadingMode.modeline,
)


Expand Down
27 changes: 20 additions & 7 deletions src/check_jsonschema/cli/parse_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class SchemaLoadingMode(enum.Enum):
filepath = "filepath"
builtin = "builtin"
metaschema = "metaschema"
modeline = "modeline"


class ParseResult:
Expand Down Expand Up @@ -57,20 +58,30 @@ def set_regex_variant(
self.regex_variant = RegexVariantName(variant_name)

def set_schema(
self, schemafile: str | None, builtin_schema: str | None, check_metaschema: bool
self,
schemafile: str | None,
builtin_schema: str | None,
check_metaschema: bool,
schema_from_modeline: bool = False,
) -> None:
mutex_arg_count = sum(
1 if x else 0 for x in (schemafile, builtin_schema, check_metaschema)
1 if x else 0
for x in (
schemafile,
builtin_schema,
check_metaschema,
schema_from_modeline,
)
)
if mutex_arg_count == 0:
raise click.UsageError(
"Either --schemafile, --builtin-schema, or --check-metaschema "
"must be provided"
"Either --schemafile, --builtin-schema, --check-metaschema, "
"or --schema-from-modeline must be provided"
)
if mutex_arg_count > 1:
raise click.UsageError(
"--schemafile, --builtin-schema, and --check-metaschema "
"are mutually exclusive"
"--schemafile, --builtin-schema, --check-metaschema, and "
"--schema-from-modeline are mutually exclusive"
)

if schemafile:
Expand All @@ -79,8 +90,10 @@ def set_schema(
elif builtin_schema:
self.schema_mode = SchemaLoadingMode.builtin
self.schema_path = builtin_schema
else:
elif check_metaschema:
self.schema_mode = SchemaLoadingMode.metaschema
else:
self.schema_mode = SchemaLoadingMode.modeline

def set_validator(
self, validator_class: type[jsonschema.protocols.Validator] | None
Expand Down
Loading