Repository: python-jsonschema/check-jsonschema

Issue:
pre-populate default values from schema (command-line option)
Hi, 

I think it would be very useful for validating json documents against schemas a command-line option that would allow to pre-populate defaults into the document like python-jsonshema docs suggest here:

https://python-jsonschema.readthedocs.io/en/stable/faq/#why-doesn-t-my-schema-s-default-property-set-the-default-on-my-instance

It would make easy to validate documents like it is suggested just from the command-line tool



Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index df28630..c7b5594 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -11,6 +11,9 @@ Unreleased
 .. vendor-insert-here
 
 - Update vendored schemas (2023-01-02)
+- Add ``--fill-defaults`` argument which eagerly populates ``"default"``
+  values whenever they are encountered and a value is not already present
+  (:issue:`200`)
 
 0.19.2
 ------
diff --git a/docs/usage.rst b/docs/usage.rst
index 7c84b71..d114ced 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -152,6 +152,22 @@ checked. The following transforms are supported:
     interpret ``!reference`` usages -- it only expands them to lists of strings
     to pass schema validation
 
+``--fill-defaults``
+-------------------
+
+JSON Schema specifies the ``"default"`` keyword as potentially meaningful for
+consumers of schemas, but not for validators. Therefore, the default behavior
+for ``check-jsonschema`` is to ignore ``"default"``.
+
+``--fill-defaults`` changes this behavior, filling in ``"default"`` values
+whenever they are encountered prior to validation.
+
+.. warning::
+
+    There are many schemas which make the meaning of ``"default"`` unclear.
+    In particular, the behavior of ``check-jsonschema`` is undefined when multiple
+    defaults are specified via ``anyOf``, ``oneOf``, or other forms of polymorphism.
+
 "format" Validation Options
 ---------------------------
 
diff --git a/src/check_jsonschema/checker.py b/src/check_jsonschema/checker.py
index c8cf007..1ecb9c8 100644
--- a/src/check_jsonschema/checker.py
+++ b/src/check_jsonschema/checker.py
@@ -29,6 +29,7 @@ class SchemaChecker:
         *,
         format_opts: FormatOptions | None = None,
         traceback_mode: str = "short",
+        fill_defaults: bool = False,
     ):
         self._schema_loader = schema_loader
         self._instance_loader = instance_loader
@@ -36,6 +37,7 @@ class SchemaChecker:
 
         self._format_opts = format_opts if format_opts is not None else FormatOptions()
         self._traceback_mode = traceback_mode
+        self._fill_defaults = fill_defaults
 
     def _fail(self, msg: str, err: Exception | None = None) -> t.NoReturn:
         click.echo(msg, err=True)
@@ -47,7 +49,9 @@ class SchemaChecker:
         self, path: pathlib.Path, doc: dict[str, t.Any]
     ) -> jsonschema.Validator:
         try:
-            return self._schema_loader.get_validator(path, doc, self._format_opts)
+            return self._schema_loader.get_validator(
+                path, doc, self._format_opts, self._fill_defaults
+            )
         except SchemaParseError as e:
             self._fail("Error: schemafile could not be parsed as JSON", e)
         except jsonschema.SchemaError as e:
diff --git a/src/check_jsonschema/cli.py b/src/check_jsonschema/cli.py
index 61f384a..ebfff6b 100644
--- a/src/check_jsonschema/cli.py
+++ b/src/check_jsonschema/cli.py
@@ -52,6 +52,8 @@ class ParseResult:
         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
+        self.fill_defaults: bool = False
         # regex format options
         self.disable_format: bool = False
         self.format_regex: RegexFormatBehavior = RegexFormatBehavior.default
@@ -197,6 +199,11 @@ The '--builtin-schema' flag supports the following schema names:
     ),
     type=click.Choice(tuple(TRANSFORM_LIBRARY.keys())),
 )
+@click.option(
+    "--fill-defaults",
+    help="Autofill 'default' values prior to validation.",
+    is_flag=True,
+)
 @click.option(
     "-o",
     "--output-format",
@@ -234,6 +241,7 @@ def main(
     default_filetype: str,
     traceback_mode: str,
     data_transform: str | None,
+    fill_defaults: bool,
     output_format: str,
     verbose: int,
     quiet: int,
@@ -249,6 +257,7 @@ def main(
     args.format_regex = RegexFormatBehavior(format_regex)
     args.disable_cache = no_cache
     args.default_filetype = default_filetype
+    args.fill_defaults = fill_defaults
     if cache_filename is not None:
         args.cache_filename = cache_filename
     if data_transform is not None:
@@ -304,6 +313,7 @@ def build_checker(args: ParseResult) -> SchemaChecker:
         reporter,
         format_opts=args.format_opts,
         traceback_mode=args.traceback_mode,
+        fill_defaults=args.fill_defaults,
     )
 
 
diff --git a/src/check_jsonschema/schema_loader/main.py b/src/check_jsonschema/schema_loader/main.py
index b40e83d..3e1cae2 100644
--- a/src/check_jsonschema/schema_loader/main.py
+++ b/src/check_jsonschema/schema_loader/main.py
@@ -15,12 +15,41 @@ from .readers import HttpSchemaReader, LocalSchemaReader
 from .resolver import make_ref_resolver
 
 
+def _extend_with_default(
+    validator_class: type[jsonschema.Validator],
+) -> type[jsonschema.Validator]:
+    validate_properties = validator_class.VALIDATORS["properties"]
+
+    def set_defaults_then_validate(
+        validator: jsonschema.Validator,
+        properties: dict[str, dict[str, t.Any]],
+        instance: dict[str, t.Any],
+        schema: dict[str, t.Any],
+    ) -> t.Iterator[jsonschema.ValidationError]:
+        for property_name, subschema in properties.items():
+            if "default" in subschema and property_name not in instance:
+                instance[property_name] = subschema["default"]
+
+        yield from validate_properties(
+            validator,
+            properties,
+            instance,
+            schema,
+        )
+
+    return jsonschema.validators.extend(
+        validator_class,
+        {"properties": set_defaults_then_validate},
+    )
+
+
 class SchemaLoaderBase:
     def get_validator(
         self,
         path: pathlib.Path,
         instance_doc: dict[str, t.Any],
         format_opts: FormatOptions,
+        fill_defaults: bool,
     ) -> jsonschema.Validator:
         raise NotImplementedError
 
@@ -76,7 +105,9 @@ class SchemaLoader(SchemaLoaderBase):
     def get_schema(self) -> dict[str, t.Any]:
         return self.reader.read_schema()
 
-    def make_validator(self, format_opts: FormatOptions) -> jsonschema.Validator:
+    def make_validator(
+        self, format_opts: FormatOptions, fill_defaults: bool
+    ) -> jsonschema.Validator:
         schema_uri = self.get_schema_ref_base()
         schema = self.get_schema()
 
@@ -95,6 +126,10 @@ class SchemaLoader(SchemaLoaderBase):
         validator_cls = jsonschema.validators.validator_for(schema)
         validator_cls.check_schema(schema)
 
+        # extend the validator class with default-filling behavior if appropriate
+        if fill_defaults:
+            validator_cls = _extend_with_default(validator_cls)
+
         # now that we know it's safe to try to create the validator instance, do it
         validator = validator_cls(
             schema,
@@ -108,8 +143,9 @@ class SchemaLoader(SchemaLoaderBase):
         path: pathlib.Path,
         instance_doc: dict[str, t.Any],
         format_opts: FormatOptions,
+        fill_defaults: bool,
     ) -> jsonschema.Validator:
-        self._validator = self.make_validator(format_opts)
+        self._validator = self.make_validator(format_opts, fill_defaults)
         return self._validator
 
 
@@ -130,6 +166,7 @@ class MetaSchemaLoader(SchemaLoaderBase):
         path: pathlib.Path,
         instance_doc: dict[str, t.Any],
         format_opts: FormatOptions,
+        fill_defaults: bool,
     ) -> jsonschema.Validator:
         validator = jsonschema.validators.validator_for(instance_doc)
         return t.cast(jsonschema.Validator, validator(validator.META_SCHEMA))


Test patch:
diff --git a/tests/acceptance/test_fill_defaults.py b/tests/acceptance/test_fill_defaults.py
new file mode 100644
index 0000000..5d96302
--- /dev/null
+++ b/tests/acceptance/test_fill_defaults.py
@@ -0,0 +1,82 @@
+import json
+
+SCHEMA = {
+    "$schema": "http://json-schema.org/draft-07/schema",
+    "properties": {
+        "title": {
+            "type": "string",
+            "default": "Untitled",
+        },
+    },
+    "required": ["title"],
+}
+
+VALID_DOC = {
+    "title": "doc one",
+}
+
+INVALID_DOC = {"title": {"foo": "bar"}}
+
+MISSING_FIELD_DOC = {}
+
+
+def test_run_with_fill_defaults_does_not_make_valid_doc_invalid(
+    run_line_simple, tmp_path
+):
+    schemafile = tmp_path / "schema.json"
+    schemafile.write_text(json.dumps(SCHEMA))
+
+    doc = tmp_path / "instance.json"
+    doc.write_text(json.dumps(VALID_DOC))
+
+    run_line_simple(["--fill-defaults", "--schemafile", str(schemafile), str(doc)])
+
+
+def test_run_with_fill_defaults_does_not_make_invalid_doc_valid(run_line, tmp_path):
+    schemafile = tmp_path / "schema.json"
+    schemafile.write_text(json.dumps(SCHEMA))
+
+    doc = tmp_path / "instance.json"
+    doc.write_text(json.dumps(INVALID_DOC))
+
+    res = run_line(
+        [
+            "check-jsonschema",
+            "--fill-defaults",
+            "--schemafile",
+            str(schemafile),
+            str(doc),
+        ]
+    )
+    assert res.exit_code == 1
+
+
+def test_run_with_fill_defaults_adds_required_field(run_line, tmp_path):
+    schemafile = tmp_path / "schema.json"
+    schemafile.write_text(json.dumps(SCHEMA))
+
+    doc = tmp_path / "instance.json"
+    doc.write_text(json.dumps(MISSING_FIELD_DOC))
+
+    # step 1: run without '--fill-defaults' and confirm failure
+    result_without_fill_defaults = run_line(
+        [
+            "check-jsonschema",
+            "--schemafile",
+            str(schemafile),
+            str(doc),
+        ]
+    )
+    assert result_without_fill_defaults.exit_code == 1
+
+    # step 2: run with '--fill-defaults' and confirm success
+    result_with_fill_defaults = run_line(
+        [
+            "check-jsonschema",
+            "--fill-defaults",
+            "--schemafile",
+            str(schemafile),
+            str(doc),
+        ]
+    )
+    assert result_with_fill_defaults.exit_code == 0