Skip to content

Commit e45abd3

Browse files
authored
Implement rudimentary --fill-defaults flag (#215)
Docs list this among the various flags, three acceptance tests exercise the range of expected/desired functionality, and the changelog lists the new feature. No explicit handling is added for polymophic schemas, incorrect `"default"` values, or other corner cases, but the docs include a warning that `"default"` can be ambiguous or even ill-defined. Although the behavior, inherited from `jsonschema`, is deterministic for how `"default"` under a polymorphic schema is handled, it is documented explicitly as undefined.
1 parent 67790e8 commit e45abd3

File tree

6 files changed

+155
-3
lines changed

6 files changed

+155
-3
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Unreleased
1111
.. vendor-insert-here
1212
1313
- Update vendored schemas (2023-01-02)
14+
- Add ``--fill-defaults`` argument which eagerly populates ``"default"``
15+
values whenever they are encountered and a value is not already present
16+
(:issue:`200`)
1417

1518
0.19.2
1619
------

docs/usage.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,22 @@ checked. The following transforms are supported:
152152
interpret ``!reference`` usages -- it only expands them to lists of strings
153153
to pass schema validation
154154

155+
``--fill-defaults``
156+
-------------------
157+
158+
JSON Schema specifies the ``"default"`` keyword as potentially meaningful for
159+
consumers of schemas, but not for validators. Therefore, the default behavior
160+
for ``check-jsonschema`` is to ignore ``"default"``.
161+
162+
``--fill-defaults`` changes this behavior, filling in ``"default"`` values
163+
whenever they are encountered prior to validation.
164+
165+
.. warning::
166+
167+
There are many schemas which make the meaning of ``"default"`` unclear.
168+
In particular, the behavior of ``check-jsonschema`` is undefined when multiple
169+
defaults are specified via ``anyOf``, ``oneOf``, or other forms of polymorphism.
170+
155171
"format" Validation Options
156172
---------------------------
157173

src/check_jsonschema/checker.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ def __init__(
2929
*,
3030
format_opts: FormatOptions | None = None,
3131
traceback_mode: str = "short",
32+
fill_defaults: bool = False,
3233
):
3334
self._schema_loader = schema_loader
3435
self._instance_loader = instance_loader
3536
self._reporter = reporter
3637

3738
self._format_opts = format_opts if format_opts is not None else FormatOptions()
3839
self._traceback_mode = traceback_mode
40+
self._fill_defaults = fill_defaults
3941

4042
def _fail(self, msg: str, err: Exception | None = None) -> t.NoReturn:
4143
click.echo(msg, err=True)
@@ -47,7 +49,9 @@ def get_validator(
4749
self, path: pathlib.Path, doc: dict[str, t.Any]
4850
) -> jsonschema.Validator:
4951
try:
50-
return self._schema_loader.get_validator(path, doc, self._format_opts)
52+
return self._schema_loader.get_validator(
53+
path, doc, self._format_opts, self._fill_defaults
54+
)
5155
except SchemaParseError as e:
5256
self._fail("Error: schemafile could not be parsed as JSON", e)
5357
except jsonschema.SchemaError as e:

src/check_jsonschema/cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def __init__(self) -> None:
5252
self.default_filetype: str = "json"
5353
# data-transform (for Azure Pipelines and potentially future transforms)
5454
self.data_transform: Transform | None = None
55+
# fill default values on instances during validation
56+
self.fill_defaults: bool = False
5557
# regex format options
5658
self.disable_format: bool = False
5759
self.format_regex: RegexFormatBehavior = RegexFormatBehavior.default
@@ -197,6 +199,11 @@ def format_opts(self) -> FormatOptions:
197199
),
198200
type=click.Choice(tuple(TRANSFORM_LIBRARY.keys())),
199201
)
202+
@click.option(
203+
"--fill-defaults",
204+
help="Autofill 'default' values prior to validation.",
205+
is_flag=True,
206+
)
200207
@click.option(
201208
"-o",
202209
"--output-format",
@@ -234,6 +241,7 @@ def main(
234241
default_filetype: str,
235242
traceback_mode: str,
236243
data_transform: str | None,
244+
fill_defaults: bool,
237245
output_format: str,
238246
verbose: int,
239247
quiet: int,
@@ -249,6 +257,7 @@ def main(
249257
args.format_regex = RegexFormatBehavior(format_regex)
250258
args.disable_cache = no_cache
251259
args.default_filetype = default_filetype
260+
args.fill_defaults = fill_defaults
252261
if cache_filename is not None:
253262
args.cache_filename = cache_filename
254263
if data_transform is not None:
@@ -304,6 +313,7 @@ def build_checker(args: ParseResult) -> SchemaChecker:
304313
reporter,
305314
format_opts=args.format_opts,
306315
traceback_mode=args.traceback_mode,
316+
fill_defaults=args.fill_defaults,
307317
)
308318

309319

src/check_jsonschema/schema_loader/main.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,41 @@
1515
from .resolver import make_ref_resolver
1616

1717

18+
def _extend_with_default(
19+
validator_class: type[jsonschema.Validator],
20+
) -> type[jsonschema.Validator]:
21+
validate_properties = validator_class.VALIDATORS["properties"]
22+
23+
def set_defaults_then_validate(
24+
validator: jsonschema.Validator,
25+
properties: dict[str, dict[str, t.Any]],
26+
instance: dict[str, t.Any],
27+
schema: dict[str, t.Any],
28+
) -> t.Iterator[jsonschema.ValidationError]:
29+
for property_name, subschema in properties.items():
30+
if "default" in subschema and property_name not in instance:
31+
instance[property_name] = subschema["default"]
32+
33+
yield from validate_properties(
34+
validator,
35+
properties,
36+
instance,
37+
schema,
38+
)
39+
40+
return jsonschema.validators.extend(
41+
validator_class,
42+
{"properties": set_defaults_then_validate},
43+
)
44+
45+
1846
class SchemaLoaderBase:
1947
def get_validator(
2048
self,
2149
path: pathlib.Path,
2250
instance_doc: dict[str, t.Any],
2351
format_opts: FormatOptions,
52+
fill_defaults: bool,
2453
) -> jsonschema.Validator:
2554
raise NotImplementedError
2655

@@ -76,7 +105,9 @@ def get_schema_ref_base(self) -> str | None:
76105
def get_schema(self) -> dict[str, t.Any]:
77106
return self.reader.read_schema()
78107

79-
def make_validator(self, format_opts: FormatOptions) -> jsonschema.Validator:
108+
def make_validator(
109+
self, format_opts: FormatOptions, fill_defaults: bool
110+
) -> jsonschema.Validator:
80111
schema_uri = self.get_schema_ref_base()
81112
schema = self.get_schema()
82113

@@ -95,6 +126,10 @@ def make_validator(self, format_opts: FormatOptions) -> jsonschema.Validator:
95126
validator_cls = jsonschema.validators.validator_for(schema)
96127
validator_cls.check_schema(schema)
97128

129+
# extend the validator class with default-filling behavior if appropriate
130+
if fill_defaults:
131+
validator_cls = _extend_with_default(validator_cls)
132+
98133
# now that we know it's safe to try to create the validator instance, do it
99134
validator = validator_cls(
100135
schema,
@@ -108,8 +143,9 @@ def get_validator(
108143
path: pathlib.Path,
109144
instance_doc: dict[str, t.Any],
110145
format_opts: FormatOptions,
146+
fill_defaults: bool,
111147
) -> jsonschema.Validator:
112-
self._validator = self.make_validator(format_opts)
148+
self._validator = self.make_validator(format_opts, fill_defaults)
113149
return self._validator
114150

115151

@@ -130,6 +166,7 @@ def get_validator(
130166
path: pathlib.Path,
131167
instance_doc: dict[str, t.Any],
132168
format_opts: FormatOptions,
169+
fill_defaults: bool,
133170
) -> jsonschema.Validator:
134171
validator = jsonschema.validators.validator_for(instance_doc)
135172
return t.cast(jsonschema.Validator, validator(validator.META_SCHEMA))
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import json
2+
3+
SCHEMA = {
4+
"$schema": "http://json-schema.org/draft-07/schema",
5+
"properties": {
6+
"title": {
7+
"type": "string",
8+
"default": "Untitled",
9+
},
10+
},
11+
"required": ["title"],
12+
}
13+
14+
VALID_DOC = {
15+
"title": "doc one",
16+
}
17+
18+
INVALID_DOC = {"title": {"foo": "bar"}}
19+
20+
MISSING_FIELD_DOC = {}
21+
22+
23+
def test_run_with_fill_defaults_does_not_make_valid_doc_invalid(
24+
run_line_simple, tmp_path
25+
):
26+
schemafile = tmp_path / "schema.json"
27+
schemafile.write_text(json.dumps(SCHEMA))
28+
29+
doc = tmp_path / "instance.json"
30+
doc.write_text(json.dumps(VALID_DOC))
31+
32+
run_line_simple(["--fill-defaults", "--schemafile", str(schemafile), str(doc)])
33+
34+
35+
def test_run_with_fill_defaults_does_not_make_invalid_doc_valid(run_line, tmp_path):
36+
schemafile = tmp_path / "schema.json"
37+
schemafile.write_text(json.dumps(SCHEMA))
38+
39+
doc = tmp_path / "instance.json"
40+
doc.write_text(json.dumps(INVALID_DOC))
41+
42+
res = run_line(
43+
[
44+
"check-jsonschema",
45+
"--fill-defaults",
46+
"--schemafile",
47+
str(schemafile),
48+
str(doc),
49+
]
50+
)
51+
assert res.exit_code == 1
52+
53+
54+
def test_run_with_fill_defaults_adds_required_field(run_line, tmp_path):
55+
schemafile = tmp_path / "schema.json"
56+
schemafile.write_text(json.dumps(SCHEMA))
57+
58+
doc = tmp_path / "instance.json"
59+
doc.write_text(json.dumps(MISSING_FIELD_DOC))
60+
61+
# step 1: run without '--fill-defaults' and confirm failure
62+
result_without_fill_defaults = run_line(
63+
[
64+
"check-jsonschema",
65+
"--schemafile",
66+
str(schemafile),
67+
str(doc),
68+
]
69+
)
70+
assert result_without_fill_defaults.exit_code == 1
71+
72+
# step 2: run with '--fill-defaults' and confirm success
73+
result_with_fill_defaults = run_line(
74+
[
75+
"check-jsonschema",
76+
"--fill-defaults",
77+
"--schemafile",
78+
str(schemafile),
79+
str(doc),
80+
]
81+
)
82+
assert result_with_fill_defaults.exit_code == 0

0 commit comments

Comments
 (0)