From e8a9fae1fc22cc5a9d72033cf9f440c2785eb96d Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Mon, 29 Jun 2026 13:59:14 +0100 Subject: [PATCH 01/12] Add additionalProperties option Use `additionalProperties` instead of `unevaluatedProperties` (and flatten property list, as `additionalProperties` needs it) Signed-off-by: Arthit Suriyawongkul --- README.md | 43 ++++ pyproject.toml | 3 + src/shacl2code/lang/common.py | 4 + src/shacl2code/lang/jsonschema.py | 37 ++- src/shacl2code/lang/templates/jsonschema.j2 | 94 ++++++- tests/conftest.py | 23 ++ tests/test_jsonschema.py | 268 +++++++++++++++----- 7 files changed, 392 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 2758a53d..a4612e8a 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,49 @@ shacl2code generate -i spdx-model.json-ld -u spdx-context.jsonld https://spdx.or Note that the spdx-context.jsonld file should match the model described in the spdx-model.json-ld file. +#### JSON Schema draft version + +The default schema targets [Draft 2020-12][json-schema-2020-12] rather than +[Draft 2019-09][json-schema-2019-09] because the annotation-collection semantics +of `unevaluatedProperties` across `if/then/else` and `$ref` were underspecified +in 2019-09 and clarified in 2020-12. + +With `--use-additional-properties`, `unevaluatedProperties` is not used, +so the schema targets [Draft 2019-09][json-schema-2019-09] -- the minimum for +the remaining keywords. + +#### `--use-additional-properties` + +The `--use-additional-properties` flag switches the property-restriction +keyword from `unevaluatedProperties` to `additionalProperties` by inlining +all inherited properties directly into each class definition instead of +composing them via `$ref` chains. + +**When to use it:** + +- **Performance:** some JSON Schema validators (e.g. [json-schema-validator] + 2.0.x) have [performance regressions][perf-issue] with `unevaluatedProperties` + because it requires tracking annotation state across the full evaluation tree. + `additionalProperties` is a simpler local lookup and avoids that cost. +- **Draft 2020-12 not supported:** use this flag to get a schema that targets + [Draft 2019-09][json-schema-2019-09]. + +**Trade-off:** + +- The generated schema is larger because every class includes its full set of + inherited property definitions inline. For load-once/validate-many use cases + the size cost is amortized over many validation runs. +- Validation semantics are equivalent for well-formed documents. For malformed + documents, one known difference: `@context` is permitted on embedded objects + (it is added to every class's inlined property list so that root-level + documents, which carry `@context`, pass `additionalProperties: false`). + In default mode, `@context` on an embedded object is rejected. + +[json-schema-2019-09]: https://json-schema.org/draft/2019-09 +[json-schema-2020-12]: https://json-schema.org/draft/2020-12 +[json-schema-validator]: https://github.com/networknt/json-schema-validator +[perf-issue]: https://github.com/networknt/json-schema-validator/issues/1216 + ## Developing Developing on `shacl2code` is best done using a virtual environment. You can diff --git a/pyproject.toml b/pyproject.toml index 6695adc9..559dc963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,9 @@ addopts = [ "auto", "--dist=loadfile", ] +markers = [ + "network: marks tests that require network access (deselect with '-m \"not network\"')", +] # Suppress 3rd-party warnings to reduce log noise. Remove after libraries get updated. filterwarnings = [ "ignore:.*ConjunctiveGraph is deprecated.*:DeprecationWarning", # From rdflib diff --git a/src/shacl2code/lang/common.py b/src/shacl2code/lang/common.py index e6bc4a25..3988be76 100644 --- a/src/shacl2code/lang/common.py +++ b/src/shacl2code/lang/common.py @@ -51,6 +51,9 @@ def get_additional_render_args(self, model): def get_extra_env(self): return {} + def get_extra_model_env(self, classes): + return {} + def render(self, template, output, *, extra_env=None, render_args=None): if extra_env is None: extra_env = {} # pragma: no cover @@ -139,6 +142,7 @@ def get_all_named_individuals(cls): "get_all_named_individuals": get_all_named_individuals, "include_file": include_file, **self.get_extra_env(), + **self.get_extra_model_env(classes), } for output, template, args in self.get_outputs(): diff --git a/src/shacl2code/lang/jsonschema.py b/src/shacl2code/lang/jsonschema.py index 295dd88d..2ba72d89 100644 --- a/src/shacl2code/lang/jsonschema.py +++ b/src/shacl2code/lang/jsonschema.py @@ -1,6 +1,7 @@ -# -# Copyright (c) 2024 Joshua Watt -# +# SPDX-FileContributor: Joshua Watt +# SPDX-FileContributor: Arthit Suriyawongkul +# SPDX-FileCopyrightText: 2024-present Joshua Watt +# SPDX-FileType: SOURCE # SPDX-License-Identifier: MIT """JSON Schema renderer""" @@ -29,6 +30,7 @@ def __init__(self, args): "schema_title": args.title, "schema_id": args.id, "allow_elided_lists": args.allow_elided_lists, + "use_additional_properties": args.use_additional_properties, } @classmethod @@ -42,11 +44,40 @@ def get_arguments(cls, parser): action="store_true", help="Allow lists to be elided if they only contain a single element", ) + parser.add_argument( + "--use-additional-properties", + action="store_true", + help=( + "Use additionalProperties instead of unevaluatedProperties. " + "Flattens inherited property refs into each class definition for " + "better compatibility with validators that have poor " + "unevaluatedProperties performance." + ), + ) def get_extra_env(self): return { "varname": varname, } + def get_extra_model_env(self, classes): + def get_all_properties(cls): + """Return [(defining_class, prop), ...] for cls and all ancestors, parent-first, no duplicates.""" + seen = set() + result = [] + + def collect(c): + for parent_id in c.parent_ids: + collect(classes.get(parent_id)) + for prop in c.properties: + if prop.path not in seen: + seen.add(prop.path) + result.append((c, prop)) + + collect(cls) + return result + + return {"get_all_properties": get_all_properties} + def get_additional_render_args(self, model): return self.__render_args diff --git a/src/shacl2code/lang/templates/jsonschema.j2 b/src/shacl2code/lang/templates/jsonschema.j2 index a43ff374..f5e58f19 100644 --- a/src/shacl2code/lang/templates/jsonschema.j2 +++ b/src/shacl2code/lang/templates/jsonschema.j2 @@ -1,5 +1,5 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", + "$schema": "{% if use_additional_properties %}https://json-schema.org/draft/2019-09/schema{% else %}https://json-schema.org/draft/2020-12/schema{% endif %}", {%- if schema_id %} "$id": "{{ schema_id }}", {%- endif %} @@ -42,35 +42,44 @@ "then": { "type": "object", "properties": { + {%- if use_additional_properties %} + "@context": {}, + {%- endif %} "@graph": { "description": "Top level container for JSON-LD objects", "type": "array", "items": { "type": "object", - "$ref": "#/$defs/AnyClass", + "$ref": "#/$defs/AnyClass" + {%- if not use_additional_properties %}, "unevaluatedProperties": false + {%- endif %} } } }, "required": ["@graph"] + {%- if use_additional_properties %}, + "additionalProperties": false + {%- endif %} }, "else": { "$ref": "#/$defs/AnyClass" }, + {%- if not use_additional_properties %} "unevaluatedProperties": false, + {%- endif %} "$defs": { {%- for class in classes %} - {#- Classes are divided into 2 parts. The properties are separated into a separate object #} - {#- so that a object can references the properties of its parent without needing the const #} - {#- @type tag #} + {#- Default: _props splits inherited properties for parent reuse without repeating @type. #} + {#- --use-additional-properties: all inherited properties inlined per class. #} {%- if not class.is_abstract or class.is_extensible %} "{{ varname(*class.clsname) }}": { "if": { "type": "object", "properties": { "{{ context.compact_iri("@type") }}": { - {#- Abstract Extensible classes are weird; any type _except_ the specific class type is allowed #} + {#- Abstract extensible: any IRI @type except the class's own is valid #} {%- if class.is_abstract and class.is_extensible %} "allOf": [ { "$ref": "#/$defs/IRI" }, @@ -88,6 +97,68 @@ }, "required": ["{{ context.compact_iri("@type") }}"] }, + {%- if use_additional_properties %} + {%- set all_props = get_all_properties(class) %} + {%- set required = [] %} + {%- if class.node_kind == SH.IRI %} + {{- required.append(class.id_property or "@id") or "" }} + {%- endif %} + {%- for _, prop in all_props %} + {%- if not prop.min_count is none and prop.min_count > 0 %} + {{- required.append(context.compact_vocab(prop.path)) or "" }} + {%- endif %} + {%- endfor %} + "then": { + "type": "object", + {%- if class.deprecated %} + "deprecated": true, + {%- endif %} + "properties": { + "{{ class.id_property or "@id" }}": { "$ref": "#/$defs/{{ class.node_kind.split("#")[-1] }}" }, + "{{ context.compact_iri("@type") }}": { "type": "string" }, + "@context": {}{% if all_props %},{% endif %} + {%- for def_class, prop in all_props %} + {%- set is_list = prop.max_count is none or prop.max_count != 1 %} + {%- set prop_ref = "#/$defs/prop_" + varname(*def_class.clsname) + "_" + varname(prop.varname) %} + "{{ context.compact_vocab(prop.path) }}": { + {%- if is_list %} + "anyOf": [ + { + "type": "array", + {%- if prop.max_count is not none %} + "maxItems": {{ prop.max_count }}, + {%- endif %} + {%- if prop.min_count is not none %} + "minItems": {{ prop.min_count }}, + {%- endif %} + "items": { + "$ref": "{{ prop_ref }}" + } + }{% if allow_elided_lists %}, + { + "$ref": "{{ prop_ref }}" + } + {%- endif %} + ] + {%- else %} + "$ref": "{{ prop_ref }}" + {%- endif %} + }{% if not loop.last %},{% endif %} + {%- endfor %} + }{% if required %}, + "required": [ + {%- for r in required %} + "{{ r }}"{% if not loop.last %},{% endif %} + {%- endfor %} + ] + {%- endif %}, + {%- if class.is_extensible %} + "additionalProperties": true + {%- else %} + "additionalProperties": false + {%- endif %} + }, + {%- else %} "then": { "allOf": [ { @@ -108,6 +179,7 @@ { "$ref": "#/$defs/{{ varname(*class.clsname) }}_props" } ] }, + {%- endif %} "else": { "const": "Not a {{ varname(*class.clsname) }}" } @@ -116,10 +188,8 @@ "{{ varname(*class.clsname) }}_derived": { {%- set ns = namespace(json_refs=[], named_individuals=[]) %} {%- for d in get_all_derived(class) + [class._id] %} - {%- if not classes.get(d).is_abstract %} + {%- if not classes.get(d).is_abstract or classes.get(d).is_extensible %} {%- set ns.json_refs = ns.json_refs + ["#/$defs/" + varname(*classes.get(d).clsname)] %} - {%- elif classes.get(d).is_extensible %} - {%- set ns.json_refs = ns.json_refs + ["#/$defs/" + varname(*classes.get(d).clsname) + "_props"] %} {%- endif %} {%- for n in classes.get(d).named_individuals %} {%- if context.compact_iri(n._id) != n._id %} @@ -131,7 +201,7 @@ {%- if ns.json_refs %} { "type": "object", - {%- if not class.is_extensible %} + {%- if not class.is_extensible and not use_additional_properties %} "unevaluatedProperties": false, {%- endif %} "anyOf": [ @@ -147,6 +217,7 @@ { "$ref": "#/$defs/BlankNodeOrIRI" } ] }, + {%- if not use_additional_properties %} "{{ varname(*class.clsname) }}_props": { "allOf": [ {%- if class.parent_ids %} @@ -201,6 +272,7 @@ } ] }, + {%- endif %} {%- for prop in class.properties %} "prop_{{ varname(*class.clsname) }}_{{ varname(prop.varname) }}": { {%- if prop.deprecated %} @@ -291,6 +363,7 @@ "anyURI": { "type": "string" }, + {%- if not use_additional_properties %} "SHACLClass": { "type": "object", "properties": { @@ -309,6 +382,7 @@ }, "required": ["{{ context.compact_iri("@type") }}"] }, + {%- endif %} "AnyClass": { "anyOf": [ {%- for class in concrete_classes %} diff --git a/tests/conftest.py b/tests/conftest.py index a80017b1..5bfb8265 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,6 +86,29 @@ def test_timezone(): time.tzset() +@pytest.fixture(scope="session") +def test_jsonschema_additional_props(model_server, test_context_url): + p = subprocess.run( + [ + "shacl2code", + "generate", + "--input", + MODEL_DIR / "test.ttl", + "--context", + test_context_url, + "jsonschema", + "--use-additional-properties", + "--output", + "-", + ], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + + yield json.loads(p.stdout) + + @pytest.fixture(scope="session") def roundtrip(tmp_path_factory, model_server): outfile = tmp_path_factory.mktemp("roundtrip") / "roundtrip.json" diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 7665c70b..ef21d3e5 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -26,80 +26,83 @@ SPDX3_CONTEXT_URL = "https://spdx.github.io/spdx-3-model/context.json" +def _check_schema_refs(schema): + """Assert every $ref in the schema points to a valid $defs entry.""" + DEF_PREFIX = "#/$defs/" + + def check_refs(d, path): + if isinstance(d, dict): + if "$ref" in d: + assert d["$ref"].startswith( + DEF_PREFIX + ), f"{''.join(path)} must start with '{DEF_PREFIX}'" + name = d["$ref"][len(DEF_PREFIX) :] + assert ( + name in schema["$defs"] + ), f"{''.join(path)}: {name} is not in $defs" + + for k, v in d.items(): + check_refs(v, path + [f".{k}"]) + + if isinstance(d, list): + for idx, v in enumerate(d): + check_refs(v, path + [f"[{idx}]"]) + + check_refs(schema, []) + + @pytest.mark.parametrize( - "args", + "generate_args,schema_args", [ - ["--input", TEST_MODEL], - ["--input", TEST_MODEL, "--context-url", TEST_CONTEXT, SPDX3_CONTEXT_URL], + (["--input", TEST_MODEL], []), + ( + ["--input", TEST_MODEL, "--context-url", TEST_CONTEXT, SPDX3_CONTEXT_URL], + [], + ), + (["--input", TEST_MODEL], ["--use-additional-properties"]), + ( + ["--input", TEST_MODEL, "--context-url", TEST_CONTEXT, SPDX3_CONTEXT_URL], + ["--use-additional-properties"], + ), ], ) class TestOutput: - def test_output_syntax(self, args): - """ - Checks that the output file is valid json syntax by parsing it with Python - """ - p = subprocess.run( - [ - "shacl2code", - "generate", - ] - + args - + [ - "jsonschema", - "--output", - "-", - ], + def _run(self, generate_args, schema_args): + return subprocess.run( + ["shacl2code", "generate"] + + generate_args + + ["jsonschema"] + + schema_args + + ["--output", "-"], check=True, stdout=subprocess.PIPE, encoding="utf-8", ) + def test_output_syntax(self, generate_args, schema_args): + """ + Checks that the output file is valid json syntax by parsing it with Python + """ + p = self._run(generate_args, schema_args) + json.loads(p.stdout) - def test_trailing_whitespace(self, args): + def test_trailing_whitespace(self, generate_args, schema_args): """ Tests that the generated file does not have trailing whitespace """ - p = subprocess.run( - [ - "shacl2code", - "generate", - ] - + args - + [ - "jsonschema", - "--output", - "-", - ], - check=True, - stdout=subprocess.PIPE, - encoding="utf-8", - ) + p = self._run(generate_args, schema_args) for num, line in enumerate(p.stdout.splitlines()): assert ( re.search(r"\s+$", line) is None ), f"Line {num + 1} has trailing whitespace" - def test_tabs(self, args): + def test_tabs(self, generate_args, schema_args): """ Tests that the output file doesn't contain tabs """ - p = subprocess.run( - [ - "shacl2code", - "generate", - ] - + args - + [ - "jsonschema", - "--output", - "-", - ], - check=True, - stdout=subprocess.PIPE, - encoding="utf-8", - ) + p = self._run(generate_args, schema_args) for num, line in enumerate(p.stdout.splitlines()): assert "\t" not in line, f"Line {num + 1} has tabs" @@ -127,25 +130,156 @@ def test_schema_type_validation(test_jsonschema, test_context_url, passes, data) jsonschema.validate(data, schema=test_jsonschema) +def _run_jsonschema_generate(generate_args, schema_args=None): + """Run shacl2code generate jsonschema; return raw stdout string.""" + p = subprocess.run( + ["shacl2code", "generate"] + + generate_args + + ["jsonschema"] + + (schema_args or []) + + ["--output", "-"], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + return p.stdout + + +def _assert_no_unevaluated_properties(schema): + if isinstance(schema, dict): + assert "unevaluatedProperties" not in schema, ( + f"unevaluatedProperties found: {schema}" + ) + for v in schema.values(): + _assert_no_unevaluated_properties(v) + elif isinstance(schema, list): + for v in schema: + _assert_no_unevaluated_properties(v) + + def test_schema_references(test_jsonschema): - DEF_PREFIX = "#/$defs/" + _check_schema_refs(test_jsonschema) - def check_refs(d, path): - if isinstance(d, dict): - if "$ref" in d: - assert d["$ref"].startswith( - DEF_PREFIX - ), f"{''.join(path)} must start with '{DEF_PREFIX}'" - name = d["$ref"][len(DEF_PREFIX) :] - assert ( - name in test_jsonschema["$defs"] - ), f"{''.join(path)}: {name} is not in $defs" - for k, v in d.items(): - check_refs(v, path + [f".{k}"]) +def test_schema_references_additional_props(): + schema = json.loads( + _run_jsonschema_generate(["--input", TEST_MODEL], ["--use-additional-properties"]) + ) + _check_schema_refs(schema) - if isinstance(d, list): - for idx, v in enumerate(d): - check_refs(v, path + [f"[{idx}]"]) - check_refs(test_jsonschema, []) +def test_schema_version_default(): + """Default mode declares Draft 2020-12 (precise semantics for unevaluatedProperties).""" + schema = json.loads(_run_jsonschema_generate(["--input", TEST_MODEL])) + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + + +def test_schema_version_additional_props(): + """--use-additional-properties mode declares Draft 2019-09 (accurate minimum, no unevaluatedProperties).""" + schema = json.loads( + _run_jsonschema_generate(["--input", TEST_MODEL], ["--use-additional-properties"]) + ) + assert schema["$schema"] == "https://json-schema.org/draft/2019-09/schema" + + +def test_no_unevaluated_properties(): + """--use-additional-properties output must not contain unevaluatedProperties.""" + schema = json.loads( + _run_jsonschema_generate(["--input", TEST_MODEL], ["--use-additional-properties"]) + ) + _assert_no_unevaluated_properties(schema) + + +@jsonvalidation.validation_tests() +def test_schema_validation_additional_props( + test_jsonschema_additional_props, test_context_url, passes, data +): + jsonvalidation.replace_context(data, test_context_url) + + if passes: + jsonschema.validate(data, schema=test_jsonschema_additional_props) + else: + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(data, schema=test_jsonschema_additional_props) + + +@jsonvalidation.type_tests() +def test_schema_type_validation_additional_props( + test_jsonschema_additional_props, test_context_url, passes, data +): + jsonvalidation.replace_context(data, test_context_url) + + if passes: + jsonschema.validate(data, schema=test_jsonschema_additional_props) + else: + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(data, schema=test_jsonschema_additional_props) + + +# --------------------------------------------------------------------------- +# SPDX model tests +# --------------------------------------------------------------------------- + +SPDX30_ARGS = [ + "--input", + "https://spdx.org/rdf/3.0.1/spdx-model.ttl", + "--input", + "https://spdx.org/rdf/3.0.1/spdx-json-serialize-annotations.ttl", + "--context-url", + "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", + "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", +] + +SPDX31_ARGS = [ + "--input", + "https://spdx.github.io/spdx-spec/v3.1-dev/rdf/spdx-model.ttl", + "--input", + "https://spdx.github.io/spdx-spec/v3.1-dev/rdf/jsonld-annotations.ttl", + "--context-url", + "https://spdx.github.io/spdx-spec/v3.1-dev/rdf/spdx-context.jsonld", + "https://spdx.org/rdf/3.1/spdx-context.jsonld", +] + + +@pytest.mark.network +@pytest.mark.parametrize( + "spdx_args,schema_args", + [ + pytest.param(SPDX30_ARGS, [], id="spdx30-default"), + pytest.param(SPDX30_ARGS, ["--use-additional-properties"], id="spdx30-additional-props"), + pytest.param(SPDX31_ARGS, [], id="spdx31-default"), + pytest.param(SPDX31_ARGS, ["--use-additional-properties"], id="spdx31-additional-props"), + ], +) +class TestSPDXOutput: + def test_output_syntax(self, spdx_args, schema_args): + """Generated SPDX schema is valid JSON.""" + output = _run_jsonschema_generate(spdx_args, schema_args) + json.loads(output) + + def test_trailing_whitespace(self, spdx_args, schema_args): + """Generated SPDX schema has no trailing whitespace.""" + output = _run_jsonschema_generate(spdx_args, schema_args) + for num, line in enumerate(output.splitlines()): + assert ( + re.search(r"\s+$", line) is None + ), f"Line {num + 1} has trailing whitespace" + + def test_tabs(self, spdx_args, schema_args): + """Generated SPDX schema has no tabs.""" + output = _run_jsonschema_generate(spdx_args, schema_args) + for num, line in enumerate(output.splitlines()): + assert "\t" not in line, f"Line {num + 1} has tabs" + + def test_schema_references(self, spdx_args, schema_args): + """All $refs in the generated SPDX schema resolve to $defs entries.""" + output = _run_jsonschema_generate(spdx_args, schema_args) + schema = json.loads(output) + _check_schema_refs(schema) + + def test_no_unevaluated_properties(self, spdx_args, schema_args): + """--use-additional-properties schema has no unevaluatedProperties.""" + if "--use-additional-properties" not in schema_args: + pytest.skip("only applies to --use-additional-properties mode") + schema = json.loads(_run_jsonschema_generate(spdx_args, schema_args)) + _assert_no_unevaluated_properties(schema) From c53f96c7c10535d93d57af83070c36e8ad6f3f0b Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Mon, 29 Jun 2026 14:09:06 +0100 Subject: [PATCH 02/12] Add size benchmark number Signed-off-by: Arthit Suriyawongkul --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a4612e8a..8e3017ae 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,11 @@ composing them via `$ref` chains. **Trade-off:** -- The generated schema is larger because every class includes its full set of - inherited property definitions inline. For load-once/validate-many use cases - the size cost is amortized over many validation runs. +- The generated schema is larger because every class inlines its full inherited + property list; size grows with the number of inherited properties per class + (SPDX 3.0: 253 KB --> 336 KB, +33%; SPDX 3.1-dev: 452 KB --> 712 KB, +58%). + For load-once/validate-many use cases the size cost is amortized over many + validation runs. - Validation semantics are equivalent for well-formed documents. For malformed documents, one known difference: `@context` is permitted on embedded objects (it is added to every class's inlined property list so that root-level From 6b4187b938498e794130f97bf032f803c60c78bc Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Mon, 29 Jun 2026 14:35:50 +0100 Subject: [PATCH 03/12] Add @context permissive test Signed-off-by: Arthit Suriyawongkul --- README.md | 5 +-- tests/conftest.py | 22 ++++++++++++ tests/test_jsonschema.py | 73 ++++++++++++++++++++++++++++++++++------ 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8e3017ae..bccba517 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ of `unevaluatedProperties` across `if/then/else` and `$ref` were underspecified in 2019-09 and clarified in 2020-12. With `--use-additional-properties`, `unevaluatedProperties` is not used, -so the schema targets [Draft 2019-09][json-schema-2019-09] -- the minimum for +so the schema targets [Draft 2019-09][json-schema-2019-09], the minimum for the remaining keywords. #### `--use-additional-properties` @@ -95,7 +95,8 @@ composing them via `$ref` chains. documents, one known difference: `@context` is permitted on embedded objects (it is added to every class's inlined property list so that root-level documents, which carry `@context`, pass `additionalProperties: false`). - In default mode, `@context` on an embedded object is rejected. + In default mode, `@context` on an embedded object is rejected + (see [`test_context_on_embedded_object_*`](tests/test_jsonschema.py)). [json-schema-2019-09]: https://json-schema.org/draft/2019-09 [json-schema-2020-12]: https://json-schema.org/draft/2020-12 diff --git a/tests/conftest.py b/tests/conftest.py index 5bfb8265..4941f1f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,14 +6,36 @@ import json import os import shutil +import socket import subprocess import time from pathlib import Path +from typing import List import pytest from testfixtures.httpserver import HTTPTestServer + +def _network_available() -> bool: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(3) + s.connect(("8.8.8.8", 53)) + return True + except OSError: + return False + + +def pytest_collection_modifyitems(items: List[pytest.Item]) -> None: + if _network_available(): + return + skip = pytest.mark.skip(reason="no network connection") + for item in items: + if item.get_closest_marker("network"): + item.add_marker(skip) + + THIS_FILE = Path(__file__) THIS_DIR = THIS_FILE.parent diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index ef21d3e5..0a02e9d1 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -4,9 +4,11 @@ # SPDX-License-Identifier: MIT import json +import os import re import subprocess from pathlib import Path +from typing import Any, Dict, List, Optional, Union import jsonschema @@ -26,7 +28,7 @@ SPDX3_CONTEXT_URL = "https://spdx.github.io/spdx-3-model/context.json" -def _check_schema_refs(schema): +def _check_schema_refs(schema: Dict[str, Any]) -> None: """Assert every $ref in the schema points to a valid $defs entry.""" DEF_PREFIX = "#/$defs/" @@ -130,7 +132,10 @@ def test_schema_type_validation(test_jsonschema, test_context_url, passes, data) jsonschema.validate(data, schema=test_jsonschema) -def _run_jsonschema_generate(generate_args, schema_args=None): +def _run_jsonschema_generate( + generate_args: List[Union[str, os.PathLike[str]]], + schema_args: Optional[List[str]] = None, +) -> str: """Run shacl2code generate jsonschema; return raw stdout string.""" p = subprocess.run( ["shacl2code", "generate"] @@ -145,11 +150,11 @@ def _run_jsonschema_generate(generate_args, schema_args=None): return p.stdout -def _assert_no_unevaluated_properties(schema): +def _assert_no_unevaluated_properties(schema: Any) -> None: if isinstance(schema, dict): - assert "unevaluatedProperties" not in schema, ( - f"unevaluatedProperties found: {schema}" - ) + assert ( + "unevaluatedProperties" not in schema + ), f"unevaluatedProperties found: {schema}" for v in schema.values(): _assert_no_unevaluated_properties(v) elif isinstance(schema, list): @@ -163,7 +168,9 @@ def test_schema_references(test_jsonschema): def test_schema_references_additional_props(): schema = json.loads( - _run_jsonschema_generate(["--input", TEST_MODEL], ["--use-additional-properties"]) + _run_jsonschema_generate( + ["--input", TEST_MODEL], ["--use-additional-properties"] + ) ) _check_schema_refs(schema) @@ -177,15 +184,55 @@ def test_schema_version_default(): def test_schema_version_additional_props(): """--use-additional-properties mode declares Draft 2019-09 (accurate minimum, no unevaluatedProperties).""" schema = json.loads( - _run_jsonschema_generate(["--input", TEST_MODEL], ["--use-additional-properties"]) + _run_jsonschema_generate( + ["--input", TEST_MODEL], ["--use-additional-properties"] + ) ) assert schema["$schema"] == "https://json-schema.org/draft/2019-09/schema" +def test_context_on_embedded_object_default_rejects(): + """Default mode rejects @context on an embedded object (unevaluatedProperties catches it).""" + schema = json.loads(_run_jsonschema_generate(["--input", TEST_MODEL])) + doc = { + "@type": "http://example.org/link-class", + "http://example.org/link-class-link-prop": { + "@type": "http://example.org/link-class", + "@context": "http://example.org/ctx", + }, + } + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(doc, schema=schema) + + +def test_context_on_embedded_object_additional_props_accepts(): + """--use-additional-properties mode permits @context on embedded objects. + + @context is added to every class's inlined property list so that root-level + documents (which carry @context) pass additionalProperties: false. As a + known side-effect, @context is also accepted on embedded objects. + """ + schema = json.loads( + _run_jsonschema_generate( + ["--input", TEST_MODEL], ["--use-additional-properties"] + ) + ) + doc = { + "@type": "http://example.org/link-class", + "http://example.org/link-class-link-prop": { + "@type": "http://example.org/link-class", + "@context": "http://example.org/ctx", + }, + } + jsonschema.validate(doc, schema=schema) + + def test_no_unevaluated_properties(): """--use-additional-properties output must not contain unevaluatedProperties.""" schema = json.loads( - _run_jsonschema_generate(["--input", TEST_MODEL], ["--use-additional-properties"]) + _run_jsonschema_generate( + ["--input", TEST_MODEL], ["--use-additional-properties"] + ) ) _assert_no_unevaluated_properties(schema) @@ -246,9 +293,13 @@ def test_schema_type_validation_additional_props( "spdx_args,schema_args", [ pytest.param(SPDX30_ARGS, [], id="spdx30-default"), - pytest.param(SPDX30_ARGS, ["--use-additional-properties"], id="spdx30-additional-props"), + pytest.param( + SPDX30_ARGS, ["--use-additional-properties"], id="spdx30-additional-props" + ), pytest.param(SPDX31_ARGS, [], id="spdx31-default"), - pytest.param(SPDX31_ARGS, ["--use-additional-properties"], id="spdx31-additional-props"), + pytest.param( + SPDX31_ARGS, ["--use-additional-properties"], id="spdx31-additional-props" + ), ], ) class TestSPDXOutput: From d29925a2563fd9d77542a4a90eb318052bf17c65 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Mon, 29 Jun 2026 15:17:24 +0100 Subject: [PATCH 04/12] Fix Python 3.8 compat issue Signed-off-by: Arthit Suriyawongkul --- tests/test_jsonschema.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 0a02e9d1..8fce7a59 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -1,8 +1,11 @@ -# -# Copyright (c) 2024 Joshua Watt -# +# SPDX-FileContributor: Joshua Watt +# SPDX-FileContributor: Arthit Suriyawongkul +# SPDX-FileCopyrightText: 2024-present Joshua Watt +# SPDX-FileType: SOURCE # SPDX-License-Identifier: MIT +from __future__ import annotations + import json import os import re From 2238c987fd778b967994a01ad3ffdc31d54b9876 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Mon, 29 Jun 2026 15:32:57 +0100 Subject: [PATCH 05/12] Add tests for get_all_properties Signed-off-by: Arthit Suriyawongkul --- tests/test_jsonschema.py | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 8fce7a59..784a249b 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -240,6 +240,93 @@ def test_no_unevaluated_properties(): _assert_no_unevaluated_properties(schema) +# --------------------------------------------------------------------------- +# get_all_properties coverage: structural assertions on --use-additional-properties schema +# --------------------------------------------------------------------------- + +# $defs keys are varname-processed (slashes/dots -> underscores, scheme stripped) +_TEST_CLASS = "http_exampleorgtestclass" +_TEST_DERIVED = "http_exampleorgtestderivedclass" +_EXTENSIBLE_CLASS = "http_exampleorgextensibleclass" + +# Property path IRIs as they appear in then.properties (full form: no context URL supplied) +_PARENT_PROP = "http://example.org/test-class/string-scalar-prop" +_OWN_PROP = "http://example.org/test-derived-class/string-prop" + + +@pytest.fixture(scope="module") +def additional_props_schema() -> Dict[str, Any]: + return json.loads( + _run_jsonschema_generate( + ["--input", TEST_MODEL], ["--use-additional-properties"] + ) + ) + + +def test_additional_props_no_props_defs( + additional_props_schema: Dict[str, Any], +) -> None: + """--use-additional-properties removes _props entries; all properties are inlined.""" + props_keys = [k for k in additional_props_schema["$defs"] if k.endswith("_props")] + assert props_keys == [], f"unexpected _props entries: {props_keys}" + + +def test_additional_props_root_class_has_context_and_additional_properties( + additional_props_schema: Dict[str, Any], +) -> None: + """Root class then block has @context and additionalProperties: false.""" + then = additional_props_schema["$defs"][_TEST_CLASS]["then"] + assert "@context" in then["properties"] + assert then["additionalProperties"] is False + + +def test_additional_props_derived_class_has_parent_prop( + additional_props_schema: Dict[str, Any], +) -> None: + """Derived class then block contains properties inherited from the parent class.""" + props = additional_props_schema["$defs"][_TEST_DERIVED]["then"]["properties"] + assert _PARENT_PROP in props, "parent property missing from derived class" + + +def test_additional_props_derived_class_has_own_prop( + additional_props_schema: Dict[str, Any], +) -> None: + """Derived class then block contains the class's own properties.""" + props = additional_props_schema["$defs"][_TEST_DERIVED]["then"]["properties"] + assert _OWN_PROP in props, "own property missing from derived class" + + +def test_additional_props_derived_class_parent_props_come_first( + additional_props_schema: Dict[str, Any], +) -> None: + """Parent properties appear before the class's own properties (parent-first order).""" + prop_keys = list( + additional_props_schema["$defs"][_TEST_DERIVED]["then"]["properties"].keys() + ) + assert prop_keys.index(_PARENT_PROP) < prop_keys.index(_OWN_PROP) + + +def test_additional_props_prop_refs_attributed_to_defining_class( + additional_props_schema: Dict[str, Any], +) -> None: + """Each property's $ref points to the class that originally defines it.""" + props = additional_props_schema["$defs"][_TEST_DERIVED]["then"]["properties"] + assert ( + "prop_http_exampleorgtestclass" in props[_PARENT_PROP]["$ref"] + ), "parent prop ref should point to test-class prop definition" + assert ( + "prop_http_exampleorgtestderivedclass" in props[_OWN_PROP]["$ref"] + ), "own prop ref should point to test-derived-class prop definition" + + +def test_additional_props_extensible_class_allows_additional_properties( + additional_props_schema: Dict[str, Any], +) -> None: + """Extensible class has additionalProperties: true with --use-additional-properties.""" + then = additional_props_schema["$defs"][_EXTENSIBLE_CLASS]["then"] + assert then["additionalProperties"] is True + + @jsonvalidation.validation_tests() def test_schema_validation_additional_props( test_jsonschema_additional_props, test_context_url, passes, data From 66f614e8ff28c430c5f7900504d3f652de3bd596 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Fri, 3 Jul 2026 19:11:21 +0100 Subject: [PATCH 06/12] Revert code dedup Signed-off-by: Arthit Suriyawongkul --- tests/test_jsonschema.py | 146 ++++++++++++++++++++++++++------------- 1 file changed, 97 insertions(+), 49 deletions(-) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 8ac42d7b..9be6af9c 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -31,31 +31,6 @@ SPDX3_CONTEXT_URL = "https://spdx.github.io/spdx-3-model/context.json" -def _check_schema_refs(schema: Dict[str, Any]) -> None: - """Assert every $ref in the schema points to a valid $defs entry.""" - DEF_PREFIX = "#/$defs/" - - def check_refs(d, path): - if isinstance(d, dict): - if "$ref" in d: - assert d["$ref"].startswith( - DEF_PREFIX - ), f"{''.join(path)} must start with '{DEF_PREFIX}'" - name = d["$ref"][len(DEF_PREFIX) :] - assert ( - name in schema["$defs"] - ), f"{''.join(path)}: {name} is not in $defs" - - for k, v in d.items(): - check_refs(v, path + [f".{k}"]) - - if isinstance(d, list): - for idx, v in enumerate(d): - check_refs(v, path + [f"[{idx}]"]) - - check_refs(schema, []) - - @pytest.mark.parametrize( "generate_args,schema_args", [ @@ -72,8 +47,11 @@ def check_refs(d, path): ], ) class TestOutput: - def _run(self, generate_args, schema_args): - return subprocess.run( + def test_output_syntax(self, generate_args, schema_args): + """ + Checks that the output file is valid json syntax by parsing it with Python + """ + p = subprocess.run( ["shacl2code", "generate"] + generate_args + ["jsonschema"] @@ -84,19 +62,22 @@ def _run(self, generate_args, schema_args): encoding="utf-8", ) - def test_output_syntax(self, generate_args, schema_args): - """ - Checks that the output file is valid json syntax by parsing it with Python - """ - p = self._run(generate_args, schema_args) - json.loads(p.stdout) def test_trailing_whitespace(self, generate_args, schema_args): """ Tests that the generated file does not have trailing whitespace """ - p = self._run(generate_args, schema_args) + p = subprocess.run( + ["shacl2code", "generate"] + + generate_args + + ["jsonschema"] + + schema_args + + ["--output", "-"], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) for num, line in enumerate(p.stdout.splitlines()): assert ( @@ -107,27 +88,31 @@ def test_tabs(self, generate_args, schema_args): """ Tests that the output file doesn't contain tabs """ - p = self._run(generate_args, schema_args) + p = subprocess.run( + ["shacl2code", "generate"] + + generate_args + + ["jsonschema"] + + schema_args + + ["--output", "-"], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) for num, line in enumerate(p.stdout.splitlines()): assert "\t" not in line, f"Line {num + 1} has tabs" - def test_ajv_compile(self, tmp_path, args): + def test_ajv_compile(self, tmp_path, generate_args, schema_args): """ Validates the generated schema against the JSON Schema meta-schema using ajv """ schema_file = tmp_path / "schema.json" subprocess.run( - [ - "shacl2code", - "generate", - ] - + args - + [ - "jsonschema", - "--output", - schema_file, - ], + ["shacl2code", "generate"] + + generate_args + + ["jsonschema"] + + schema_args + + ["--output", schema_file], check=True, ) subprocess.run( @@ -139,6 +124,7 @@ def test_ajv_compile(self, tmp_path, args): check=True, ) + @jsonvalidation.validation_tests() def test_schema_validation(test_jsonschema, test_context_url, passes, data): jsonvalidation.replace_context(data, test_context_url) @@ -192,7 +178,27 @@ def _assert_no_unevaluated_properties(schema: Any) -> None: def test_schema_references(test_jsonschema): - _check_schema_refs(test_jsonschema) + DEF_PREFIX = "#/$defs/" + + def check_refs(d, path): + if isinstance(d, dict): + if "$ref" in d: + assert d["$ref"].startswith( + DEF_PREFIX + ), f"{''.join(path)} must start with '{DEF_PREFIX}'" + name = d["$ref"][len(DEF_PREFIX) :] + assert ( + name in test_jsonschema["$defs"] + ), f"{''.join(path)}: {name} is not in $defs" + + for k, v in d.items(): + check_refs(v, path + [f".{k}"]) + + if isinstance(d, list): + for idx, v in enumerate(d): + check_refs(v, path + [f"[{idx}]"]) + + check_refs(test_jsonschema, []) def test_schema_references_additional_props(): @@ -201,7 +207,28 @@ def test_schema_references_additional_props(): ["--input", TEST_MODEL], ["--use-additional-properties"] ) ) - _check_schema_refs(schema) + + DEF_PREFIX = "#/$defs/" + + def check_refs(d, path): + if isinstance(d, dict): + if "$ref" in d: + assert d["$ref"].startswith( + DEF_PREFIX + ), f"{''.join(path)} must start with '{DEF_PREFIX}'" + name = d["$ref"][len(DEF_PREFIX) :] + assert ( + name in schema["$defs"] + ), f"{''.join(path)}: {name} is not in $defs" + + for k, v in d.items(): + check_refs(v, path + [f".{k}"]) + + if isinstance(d, list): + for idx, v in enumerate(d): + check_refs(v, path + [f"[{idx}]"]) + + check_refs(schema, []) def test_schema_version_default(): @@ -442,7 +469,28 @@ def test_schema_references(self, spdx_args, schema_args): """All $refs in the generated SPDX schema resolve to $defs entries.""" output = _run_jsonschema_generate(spdx_args, schema_args) schema = json.loads(output) - _check_schema_refs(schema) + + DEF_PREFIX = "#/$defs/" + + def check_refs(d, path): + if isinstance(d, dict): + if "$ref" in d: + assert d["$ref"].startswith( + DEF_PREFIX + ), f"{''.join(path)} must start with '{DEF_PREFIX}'" + name = d["$ref"][len(DEF_PREFIX) :] + assert ( + name in schema["$defs"] + ), f"{''.join(path)}: {name} is not in $defs" + + for k, v in d.items(): + check_refs(v, path + [f".{k}"]) + + if isinstance(d, list): + for idx, v in enumerate(d): + check_refs(v, path + [f"[{idx}]"]) + + check_refs(schema, []) def test_no_unevaluated_properties(self, spdx_args, schema_args): """--use-additional-properties schema has no unevaluatedProperties.""" From 7c25c32d5be3648abd4d93e8c34b1b75e1f38c9f Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Fri, 3 Jul 2026 19:19:25 +0100 Subject: [PATCH 07/12] Remove unnecessary formatting changes Signed-off-by: Arthit Suriyawongkul --- tests/test_jsonschema.py | 60 +++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 9be6af9c..bc97423a 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -52,18 +52,48 @@ def test_output_syntax(self, generate_args, schema_args): Checks that the output file is valid json syntax by parsing it with Python """ p = subprocess.run( - ["shacl2code", "generate"] + [ + "shacl2code", + "generate", + ] + generate_args - + ["jsonschema"] + + [ + "jsonschema", + ] + schema_args - + ["--output", "-"], + + [ + "--output", + "-", + ], check=True, stdout=subprocess.PIPE, encoding="utf-8", ) - + json.loads(p.stdout) + def test_ajv_compile(self, tmp_path, generate_args, schema_args): + """ + Validates the generated schema against the JSON Schema meta-schema using ajv + """ + schema_file = tmp_path / "schema.json" + subprocess.run( + ["shacl2code", "generate"] + + generate_args + + ["jsonschema"] + + schema_args + + ["--output", schema_file], + check=True, + ) + subprocess.run( + [ + "node", + THIS_DIR.parent / "scripts" / "ajv-compile.js", + str(schema_file), + ], + check=True, + ) + def test_trailing_whitespace(self, generate_args, schema_args): """ Tests that the generated file does not have trailing whitespace @@ -102,28 +132,6 @@ def test_tabs(self, generate_args, schema_args): for num, line in enumerate(p.stdout.splitlines()): assert "\t" not in line, f"Line {num + 1} has tabs" - def test_ajv_compile(self, tmp_path, generate_args, schema_args): - """ - Validates the generated schema against the JSON Schema meta-schema using ajv - """ - schema_file = tmp_path / "schema.json" - subprocess.run( - ["shacl2code", "generate"] - + generate_args - + ["jsonschema"] - + schema_args - + ["--output", schema_file], - check=True, - ) - subprocess.run( - [ - "node", - THIS_DIR.parent / "scripts" / "ajv-compile.js", - str(schema_file), - ], - check=True, - ) - @jsonvalidation.validation_tests() def test_schema_validation(test_jsonschema, test_context_url, passes, data): From 46d966d16ba22ab727edeca890a3bc9da5785462 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Fri, 3 Jul 2026 19:23:04 +0100 Subject: [PATCH 08/12] Remove unnecessary formatting changes Signed-off-by: Arthit Suriyawongkul --- tests/test_jsonschema.py | 58 +++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index bc97423a..2e2a90a9 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -69,7 +69,7 @@ def test_output_syntax(self, generate_args, schema_args): stdout=subprocess.PIPE, encoding="utf-8", ) - + json.loads(p.stdout) def test_ajv_compile(self, tmp_path, generate_args, schema_args): @@ -78,11 +78,19 @@ def test_ajv_compile(self, tmp_path, generate_args, schema_args): """ schema_file = tmp_path / "schema.json" subprocess.run( - ["shacl2code", "generate"] + [ + "shacl2code", + "generate", + ] + generate_args - + ["jsonschema"] + + [ + "jsonschema", + ] + schema_args - + ["--output", schema_file], + + [ + "--output", + schema_file, + ], check=True, ) subprocess.run( @@ -99,11 +107,19 @@ def test_trailing_whitespace(self, generate_args, schema_args): Tests that the generated file does not have trailing whitespace """ p = subprocess.run( - ["shacl2code", "generate"] + [ + "shacl2code", + "generate", + ] + generate_args - + ["jsonschema"] + + [ + "jsonschema", + ] + schema_args - + ["--output", "-"], + + [ + "--output", + "-", + ], check=True, stdout=subprocess.PIPE, encoding="utf-8", @@ -119,11 +135,19 @@ def test_tabs(self, generate_args, schema_args): Tests that the output file doesn't contain tabs """ p = subprocess.run( - ["shacl2code", "generate"] + [ + "shacl2code", + "generate", + ] + generate_args - + ["jsonschema"] + + [ + "jsonschema", + ] + schema_args - + ["--output", "-"], + + [ + "--output", + "-", + ], check=True, stdout=subprocess.PIPE, encoding="utf-8", @@ -161,11 +185,19 @@ def _run_jsonschema_generate( ) -> str: """Run shacl2code generate jsonschema; return raw stdout string.""" p = subprocess.run( - ["shacl2code", "generate"] + [ + "shacl2code", + "generate", + ] + generate_args - + ["jsonschema"] + + [ + "jsonschema", + ] + (schema_args or []) - + ["--output", "-"], + + [ + "--output", + "-", + ], check=True, stdout=subprocess.PIPE, encoding="utf-8", From 82399b479d527b99655b32532966b662ff1fcf34 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Fri, 3 Jul 2026 19:26:17 +0100 Subject: [PATCH 09/12] Fix formatting Signed-off-by: Arthit Suriyawongkul --- tests/test_jsonschema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 2e2a90a9..91784f3d 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -85,7 +85,7 @@ def test_ajv_compile(self, tmp_path, generate_args, schema_args): + generate_args + [ "jsonschema", - ] + ] + schema_args + [ "--output", @@ -192,7 +192,7 @@ def _run_jsonschema_generate( + generate_args + [ "jsonschema", - ] + ] + (schema_args or []) + [ "--output", From f9073d4457a1f9b9e525275160c1a263b635fba4 Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Sat, 4 Jul 2026 00:07:32 +0100 Subject: [PATCH 10/12] README: Revise trade-off Signed-off-by: Arthit Suriyawongkul --- README.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0d4317c7..32a79944 100644 --- a/README.md +++ b/README.md @@ -129,16 +129,12 @@ composing them via `$ref` chains. **Trade-off:** -- The generated schema is larger because every class inlines its full inherited - property list; size grows with the number of inherited properties per class +- Larger schema: every class inlines its full inherited property list (SPDX 3.0: 253 KB --> 336 KB, +33%; SPDX 3.1-dev: 452 KB --> 712 KB, +58%). - For load-once/validate-many use cases the size cost is amortized over many - validation runs. -- Validation semantics are equivalent for well-formed documents. For malformed - documents, one known difference: `@context` is permitted on embedded objects - (it is added to every class's inlined property list so that root-level - documents, which carry `@context`, pass `additionalProperties: false`). - In default mode, `@context` on an embedded object is rejected +- More permissive than default mode: `@context` is accepted on embedded + (non-root) objects, not just the root, because it's added to every class's + inlined property list so root documents can pass `additionalProperties: false`. + Default mode rejects `@context` on embedded objects. (see [`test_context_on_embedded_object_*`](tests/test_jsonschema.py)). [json-schema-2019-09]: https://json-schema.org/draft/2019-09 From 2004c74377b91702dab2a02eaf9ca0849b326bfe Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Mon, 6 Jul 2026 19:37:39 +0100 Subject: [PATCH 11/12] Update tests with compact name Signed-off-by: Arthit Suriyawongkul --- tests/test_jsonschema.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 91784f3d..9694f5d6 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -291,9 +291,9 @@ def test_context_on_embedded_object_default_rejects(): """Default mode rejects @context on an embedded object (unevaluatedProperties catches it).""" schema = json.loads(_run_jsonschema_generate(["--input", TEST_MODEL])) doc = { - "@type": "http://example.org/link-class", - "http://example.org/link-class-link-prop": { - "@type": "http://example.org/link-class", + "@type": "http://example.org/shacl2code-test/link-class", + "http://example.org/shacl2code-test/link-class-link-prop": { + "@type": "http://example.org/shacl2code-test/link-class", "@context": "http://example.org/ctx", }, } @@ -314,9 +314,9 @@ def test_context_on_embedded_object_additional_props_accepts(): ) ) doc = { - "@type": "http://example.org/link-class", - "http://example.org/link-class-link-prop": { - "@type": "http://example.org/link-class", + "@type": "http://example.org/shacl2code-test/link-class", + "http://example.org/shacl2code-test/link-class-link-prop": { + "@type": "http://example.org/shacl2code-test/link-class", "@context": "http://example.org/ctx", }, } @@ -337,14 +337,17 @@ def test_no_unevaluated_properties(): # get_all_properties coverage: structural assertions on --use-additional-properties schema # --------------------------------------------------------------------------- -# $defs keys are varname-processed (slashes/dots -> underscores, scheme stripped) -_TEST_CLASS = "http_exampleorgtestclass" -_TEST_DERIVED = "http_exampleorgtestderivedclass" -_EXTENSIBLE_CLASS = "http_exampleorgextensibleclass" +# $defs keys are varname-processed (slashes/dots -> underscores, scheme stripped). +# No --context is passed to _run_jsonschema_generate here, so names fall back to +# the full model IRI (base http://example.org/shacl2code-test/) rather than a +# context-compacted form. +_TEST_CLASS = "http_exampleorgshacl2codetesttestclass" +_TEST_DERIVED = "http_exampleorgshacl2codetesttestderivedclass" +_EXTENSIBLE_CLASS = "http_exampleorgshacl2codetestextensibleclass" # Property path IRIs as they appear in then.properties (full form: no context URL supplied) -_PARENT_PROP = "http://example.org/test-class/string-scalar-prop" -_OWN_PROP = "http://example.org/test-derived-class/string-prop" +_PARENT_PROP = "http://example.org/shacl2code-test/test-class/string-scalar-prop" +_OWN_PROP = "http://example.org/shacl2code-test/test-derived-class/string-prop" @pytest.fixture(scope="module") @@ -405,10 +408,11 @@ def test_additional_props_prop_refs_attributed_to_defining_class( """Each property's $ref points to the class that originally defines it.""" props = additional_props_schema["$defs"][_TEST_DERIVED]["then"]["properties"] assert ( - "prop_http_exampleorgtestclass" in props[_PARENT_PROP]["$ref"] + "prop_http_exampleorgshacl2codetesttestclass" in props[_PARENT_PROP]["$ref"] ), "parent prop ref should point to test-class prop definition" assert ( - "prop_http_exampleorgtestderivedclass" in props[_OWN_PROP]["$ref"] + "prop_http_exampleorgshacl2codetesttestderivedclass" + in props[_OWN_PROP]["$ref"] ), "own prop ref should point to test-derived-class prop definition" From ce1a4a595bde862deb0131cdf19dc72ad3d47c0a Mon Sep 17 00:00:00 2001 From: Arthit Suriyawongkul Date: Mon, 6 Jul 2026 19:42:27 +0100 Subject: [PATCH 12/12] Fix formatting Signed-off-by: Arthit Suriyawongkul --- tests/test_jsonschema.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_jsonschema.py b/tests/test_jsonschema.py index 9694f5d6..9ccaebb7 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -411,8 +411,7 @@ def test_additional_props_prop_refs_attributed_to_defining_class( "prop_http_exampleorgshacl2codetesttestclass" in props[_PARENT_PROP]["$ref"] ), "parent prop ref should point to test-class prop definition" assert ( - "prop_http_exampleorgshacl2codetesttestderivedclass" - in props[_OWN_PROP]["$ref"] + "prop_http_exampleorgshacl2codetesttestderivedclass" in props[_OWN_PROP]["$ref"] ), "own prop ref should point to test-derived-class prop definition"