diff --git a/README.md b/README.md index 7fabdd34..3b356e5c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,48 @@ shacl2code generate \ --output schema.json ``` +#### 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:** + +- 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%). +- 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 +[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 e58cfe30..45758ae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,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 b97b8936..016af427 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..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 @@ -86,6 +108,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 62d3c569..9ccaebb7 100644 --- a/tests/test_jsonschema.py +++ b/tests/test_jsonschema.py @@ -1,13 +1,17 @@ -# SPDX-FileContributor: Arthit Suriyawongkul # SPDX-FileContributor: Joshua Watt -# SPDX-FileCopyrightText: 2024 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 import subprocess from pathlib import Path +from typing import Any, Dict, List, Optional, Union import jsonschema @@ -28,14 +32,22 @@ @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): + def test_output_syntax(self, generate_args, schema_args): """ Checks that the output file is valid json syntax by parsing it with Python """ @@ -44,9 +56,12 @@ def test_output_syntax(self, args): "shacl2code", "generate", ] - + args + + generate_args + [ "jsonschema", + ] + + schema_args + + [ "--output", "-", ], @@ -57,7 +72,7 @@ def test_output_syntax(self, args): json.loads(p.stdout) - 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 """ @@ -67,9 +82,12 @@ def test_ajv_compile(self, tmp_path, args): "shacl2code", "generate", ] - + args + + generate_args + [ "jsonschema", + ] + + schema_args + + [ "--output", schema_file, ], @@ -84,7 +102,7 @@ def test_ajv_compile(self, tmp_path, args): check=True, ) - 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 """ @@ -93,9 +111,12 @@ def test_trailing_whitespace(self, args): "shacl2code", "generate", ] - + args + + generate_args + [ "jsonschema", + ] + + schema_args + + [ "--output", "-", ], @@ -109,7 +130,7 @@ def test_trailing_whitespace(self, args): 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 """ @@ -118,9 +139,12 @@ def test_tabs(self, args): "shacl2code", "generate", ] - + args + + generate_args + [ "jsonschema", + ] + + schema_args + + [ "--output", "-", ], @@ -155,6 +179,44 @@ def test_schema_type_validation(test_jsonschema, test_context_url, passes, data) jsonschema.validate(data, schema=test_jsonschema) +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", + ] + + generate_args + + [ + "jsonschema", + ] + + (schema_args or []) + + [ + "--output", + "-", + ], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + return p.stdout + + +def _assert_no_unevaluated_properties(schema: Any) -> None: + 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/" @@ -177,3 +239,305 @@ def check_refs(d, path): check_refs(v, path + [f"[{idx}]"]) check_refs(test_jsonschema, []) + + +def test_schema_references_additional_props(): + schema = json.loads( + _run_jsonschema_generate( + ["--input", TEST_MODEL], ["--use-additional-properties"] + ) + ) + + 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(): + """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_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/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", + }, + } + 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/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", + }, + } + 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"] + ) + ) + _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). +# 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/shacl2code-test/test-class/string-scalar-prop" +_OWN_PROP = "http://example.org/shacl2code-test/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_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"] + ), "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 +): + 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) + + 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.""" + 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)