From b98b49611905fb7a854201a855dcf6c0d9267149 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 8 Sep 2026 18:09:43 +0200 Subject: [PATCH 01/24] feat(rdf): add opt-in diff-stable blank-node labels RDFC-1.0 canonicalization already makes RDF output deterministic: isomorphic graphs always serialize identically. It does not make output diffable. Blank nodes are numbered `c14nN` in a single global order, so inserting one class can renumber every blank node after it and rewrite most of the file. A one-line semantic change lands as a whole-file diff, which makes generated OWL/SHACL hard to review and noisy to keep under version control. Add a `diff_stable` argument to `canonicalize_rdf_graph()` and a `--diff-stable/--no-diff-stable` flag to the four RDF generators. When enabled, blank-node labels are derived from each node's own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels only the blank nodes it actually touches. Measured churn on a real schema (add one class, count changed lines): generator default --diff-stable owlgen 2091 17 shexgen 796 50 shaclgen 291 13 rdfgen 115 25 Output stays deterministic and isomorphic either way; only the choice of label changes. Off by default, because enabling it relabels existing output. The refinement itself lives in `diffable-rdf`, whose only dependencies (rdflib, pyoxigraph) are already linkml-runtime dependencies at higher versions, so this adds no new transitive dependencies. --- .../linkml/src/linkml/generators/owlgen.py | 28 +++- .../linkml/src/linkml/generators/rdfgen.py | 28 +++- .../linkml/src/linkml/generators/shaclgen.py | 28 +++- .../linkml/src/linkml/generators/shexgen.py | 28 +++- packages/linkml_runtime/pyproject.toml | 1 + .../linkml_runtime/utils/rdf_canonicalize.py | 23 ++- .../test_utils/test_rdf_canonicalize.py | 135 ++++++++++++++++++ 7 files changed, 266 insertions(+), 5 deletions(-) diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 50be266df0..7751eab34d 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -122,6 +122,22 @@ class OwlSchemaGenerator(Generator): """Suffix to add to the schema name to create the ontology URI, e.g. .owl.ttl""" # ObjectVars + diff_stable: bool = False + """Label blank nodes so that unrelated edits leave them untouched. + + Output is already deterministic: RDFC-1.0 guarantees that isomorphic + graphs serialize identically. It does not guarantee that *similar* + graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a + global order, so adding one class can renumber every blank node after + it and rewrite most of the file. + + When ``True``, blank-node labels are instead derived from each node's + own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels + only the blank nodes it actually touches. The output stays + deterministic and isomorphic either way; only the choice of label + changes. Off by default because enabling it relabels existing output. + """ + metadata_profile: MetadataProfile | None = None """Deprecated - use metadata_profiles.""" @@ -353,7 +369,7 @@ def serialize(self, **kwargs: Any) -> str: """ self.as_graph() fmt = "turtle" if self.format in ["owl", "ttl"] else self.format - return canonicalize_rdf_graph(self.graph, output_format=fmt) + return canonicalize_rdf_graph(self.graph, output_format=fmt, diff_stable=self.diff_stable) def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: """ @@ -1844,6 +1860,16 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef: "specified language tag. Element-level in_language overrides this." ), ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Derive blank-node labels from each node's own neighbourhood so that " + "unrelated edits leave them unchanged. Output is deterministic either " + "way; this makes successive versions of a file diff cleanly." + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile: str, metadata_profile: str, **kwargs: Any) -> None: """Generate an OWL representation of a LinkML model diff --git a/packages/linkml/src/linkml/generators/rdfgen.py b/packages/linkml/src/linkml/generators/rdfgen.py index 2da1701787..052fcff12e 100644 --- a/packages/linkml/src/linkml/generators/rdfgen.py +++ b/packages/linkml/src/linkml/generators/rdfgen.py @@ -78,6 +78,22 @@ class RDFGenerator(Generator): uses_schemaloader = True # ObjectVars + diff_stable: bool = False + """Label blank nodes so that unrelated edits leave them untouched. + + Output is already deterministic: RDFC-1.0 guarantees that isomorphic + graphs serialize identically. It does not guarantee that *similar* + graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a + global order, so adding one class can renumber every blank node after + it and rewrite most of the file. + + When ``True``, blank-node labels are instead derived from each node's + own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels + only the blank nodes it actually touches. The output stays + deterministic and isomorphic either way; only the choice of label + changes. Off by default because enabling it relabels existing output. + """ + emit_metadata: bool = False context: list[str] = None original_schema: SchemaDefinition = None @@ -89,7 +105,7 @@ def __post_init__(self): def _data(self, g: Graph) -> str: fmt = "turtle" if self.format == "ttl" else self.format - return canonicalize_rdf_graph(g, output_format=fmt) + return canonicalize_rdf_graph(g, output_format=fmt, diff_stable=self.diff_stable) def end_schema(self, output: str | None = None, context: str = None, **_) -> str: gen = JSONLDGenerator( @@ -137,6 +153,16 @@ def end_schema(self, output: str | None = None, context: str = None, **_) -> str multiple=True, help="JSONLD context file (default: vendored meta.context.jsonld)", ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Derive blank-node labels from each node's own neighbourhood so that " + "unrelated edits leave them unchanged. Output is deterministic either " + "way; this makes successive versions of a file diff cleanly." + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **kwargs): """Generate an RDF representation of a LinkML model""" diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 4731b9f0b8..5d42fea272 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -142,6 +142,22 @@ class ShaclGenerator(Generator): ignores any per-slot ``in_language``. """ + diff_stable: bool = False + """Label blank nodes so that unrelated edits leave them untouched. + + Output is already deterministic: RDFC-1.0 guarantees that isomorphic + graphs serialize identically. It does not guarantee that *similar* + graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a + global order, so adding one class can renumber every blank node after + it and rewrite most of the file. + + When ``True``, blank-node labels are instead derived from each node's + own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels + only the blank nodes it actually touches. The output stays + deterministic and isomorphic either way; only the choice of label + changes. Off by default because enabling it relabels existing output. + """ + emit_rules: bool = True """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. @@ -196,7 +212,7 @@ def generate_header(self) -> str: def serialize(self, **args) -> str: g = self.as_graph() fmt = "turtle" if self.format in ["owl", "ttl"] else self.format - return canonicalize_rdf_graph(g, output_format=fmt) + return canonicalize_rdf_graph(g, output_format=fmt, diff_stable=self.diff_stable) def as_graph(self) -> Graph: sv = self.schemaview @@ -929,6 +945,16 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: "sh:NodeShape. Use --no-emit-rules to suppress rule generation." ), ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Derive blank-node labels from each node's own neighbourhood so that " + "unrelated edits leave them unchanged. Output is deterministic either " + "way; this makes successive versions of a file diff cleanly." + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **args): """Generate SHACL turtle from a LinkML model""" diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py index 40a93ffbc9..7c43b13bc0 100644 --- a/packages/linkml/src/linkml/generators/shexgen.py +++ b/packages/linkml/src/linkml/generators/shexgen.py @@ -40,6 +40,22 @@ class ShExGenerator(Generator): uses_schemaloader = True # ObjectVars + diff_stable: bool = False + """Label blank nodes so that unrelated edits leave them untouched. + + Output is already deterministic: RDFC-1.0 guarantees that isomorphic + graphs serialize identically. It does not guarantee that *similar* + graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a + global order, so adding one class can renumber every blank node after + it and rewrite most of the file. + + When ``True``, blank-node labels are instead derived from each node's + own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels + only the blank nodes it actually touches. The output stays + deterministic and isomorphic either way; only the choice of label + changes. Off by default because enabling it relabels existing output. + """ + shex: Schema = field(default_factory=lambda: Schema()) # ShEx Schema being generated shapes: list = field(default_factory=lambda: []) shape: Shape | None = None # Current shape being defined @@ -177,7 +193,7 @@ def end_schema(self, output: str | None = None, **_) -> str: g = Graph() g.parse(data=shex, format="json-ld", version="1.1") g.bind("owl", OWL) - shex = canonicalize_rdf_graph(g, output_format="turtle") + shex = canonicalize_rdf_graph(g, output_format="turtle", diff_stable=self.diff_stable) elif self.format == "shex": g = Graph() self.namespaces.load_graph(g) @@ -258,6 +274,16 @@ def _get_subproperty_values(self, slot: SlotDefinition) -> list: help="If --expand-subproperty-of (default), slots with subproperty_of will generate NodeConstraint " "values containing all slot descendants. Use --no-expand-subproperty-of to disable this behavior.", ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Derive blank-node labels from each node's own neighbourhood so that " + "unrelated edits leave them unchanged. Output is deterministic either " + "way; this makes successive versions of a file diff cleanly." + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **args): """Generate a ShEx Schema for a LinkML model""" diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index 1ae9da3083..b879dec7ee 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", + "diffable-rdf>=0.2.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index 31c5580348..e0fadae34a 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -42,6 +42,7 @@ import pyoxigraph as ox import rdflib +from diffable_rdf import wl_relabel_quads from rdflib.compare import to_canonical_graph @@ -287,6 +288,7 @@ def _is_safe_prefix_iri(iri: str) -> bool: def canonicalize_rdf_graph( graph: rdflib.Graph, output_format: str = "turtle", + diff_stable: bool = False, ) -> str: """Serialize an rdflib Graph deterministically using RDFC-1.0 canonicalization. @@ -300,6 +302,12 @@ def canonicalize_rdf_graph( :param graph: The rdflib Graph to serialize. :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). + :param diff_stable: Derive blank-node labels from each node's own + neighbourhood instead of RDFC-1.0's global ``c14nN`` numbering, so that + editing one part of a schema does not renumber unrelated blank nodes. + Output is deterministic and isomorphic either way; only the choice of + label changes. Off by default because enabling it relabels existing + output. :return: Deterministic string serialization of the graph. """ ox_format = _FORMAT_MAP.get(output_format.lower()) @@ -339,13 +347,26 @@ def canonicalize_rdf_graph( # 3. Canonicalize blank node labels with RDFC-1.0. dataset.canonicalize(ox.CanonicalizationAlgorithm.RDFC_1_0) + quads = list(dataset) + + # 3b. Optionally re-label blank nodes with locality-sensitive hashes. + # RDFC-1.0 guarantees that identical graphs get identical labels, but it + # does not guarantee that *similar* graphs get similar labels: the labels + # are assigned by a global ordering, so inserting one blank node can + # renumber every label after it and turn a one-line semantic change into a + # whole-file diff. Weisfeiler-Lehman labels are derived only from each + # node's local neighbourhood, so unrelated regions keep their labels. + # Output stays deterministic and isomorphic either way; this only changes + # which label each blank node receives. + if diff_stable: + quads = wl_relabel_quads(quads) + # 4. Sort triples for deterministic ordering. # RDFC-1.0 stabilizes blank-node labels but pyoxigraph's Dataset # iteration order is not sorted and varies across processes (verified # empirically against pyoxigraph 0.5.8). The explicit string-key sort # is load-bearing for byte-identical output across runs; see # tests/linkml_runtime/test_utils/test_rdf_canonicalize.py::test_sort_is_load_bearing. - quads = list(dataset) sorted_triples = sorted( (ox.Triple(q.subject, q.predicate, q.object) for q in quads), key=lambda t: (str(t.subject), str(t.predicate), str(t.object)), diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 63dbf095a1..0ca79c3c90 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -507,3 +507,138 @@ def run(seed: str) -> str: assert out_a == out_b, ( "Fallback output differs across PYTHONHASHSEED values; blank-node canonicalization may be missing" ) + + +def _shapes_graph(count: int, extra: bool = False) -> Graph: + """A graph shaped like generator output: one blank node per named subject.""" + g = Graph() + g.bind("ex", "http://example.com/") + names = [f"{i:02d}" for i in range(count)] + (["AAAinserted"] if extra else []) + for name in names: + subject = URIRef(f"http://example.com/Shape{name}") + prop = BNode() + g.add((subject, URIRef("http://example.com/property"), prop)) + g.add((prop, URIRef("http://example.com/path"), URIRef(f"http://example.com/p{name}"))) + return g + + +def _changed_line_count(before: str, after: str) -> int: + import difflib + + diff = difflib.unified_diff(before.splitlines(), after.splitlines(), n=0, lineterm="") + return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) + + +def test_diff_stable_is_opt_in(): + """The default must keep producing exactly the output it produced before.""" + graph = _make_graph_with_bnodes() + assert canonicalize_rdf_graph(graph) == canonicalize_rdf_graph(graph, diff_stable=False) + + +def test_diff_stable_preserves_semantics(): + """Relabelling blank nodes must not change what the graph means.""" + graph = _make_graph_with_bnodes() + + plain = rdflib.Graph() + plain.parse(data=canonicalize_rdf_graph(graph), format="turtle") + stable = rdflib.Graph() + stable.parse(data=canonicalize_rdf_graph(graph, diff_stable=True), format="turtle") + + assert rdflib.compare.isomorphic(plain, stable) + + +def test_diff_stable_is_deterministic(): + """Diff stability must not cost determinism, which is the stronger property.""" + graph = _make_graph_with_bnodes() + outputs = {canonicalize_rdf_graph(graph, diff_stable=True) for _ in range(5)} + assert len(outputs) == 1 + + +def test_diff_stable_confines_an_insertion_to_the_lines_it_touches(): + """Inserting one subject must not relabel the blank nodes of the others. + + RDFC-1.0 numbers blank nodes ``c14nN`` in a global order, so a subject + sorting before the others shifts every subsequent label and rewrites + most of the file. This is the entire reason the option exists, so the + assertion is on the *ratio*, not on an absolute line count that would + be brittle across rdflib versions. + """ + before, after = _shapes_graph(20), _shapes_graph(20, extra=True) + + baseline = _changed_line_count(canonicalize_rdf_graph(before), canonicalize_rdf_graph(after)) + stable = _changed_line_count( + canonicalize_rdf_graph(before, diff_stable=True), + canonicalize_rdf_graph(after, diff_stable=True), + ) + + assert stable < baseline / 4, f"expected diff-stable output to churn far less; got {stable} vs baseline {baseline}" + + +_DIFF_STABLE_SCHEMA = """\ +id: https://example.org/diffstable +name: diffstable +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/diffstable/ +default_prefix: ex +default_range: string +imports: + - linkml:types +classes: + Person: + slots: [name, knows] + Organization: + slots: [name] +slots: + name: + range: string + knows: + range: Person + multivalued: true +""" + + +def _generator_cases(): + """The four generators that serialize RDF, with the args that make them do so.""" + from linkml.generators.owlgen import OwlSchemaGenerator + from linkml.generators.rdfgen import RDFGenerator + from linkml.generators.shaclgen import ShaclGenerator + from linkml.generators.shexgen import ShExGenerator + + return [ + pytest.param(OwlSchemaGenerator, {}, id="owlgen"), + # rdfgen and shexgen resolve JSON-LD contexts (linkml types, shex.jsonld); + # the `network` marker serves those from local stubs. See tests/conftest.py. + pytest.param(RDFGenerator, {}, id="rdfgen", marks=pytest.mark.network), + pytest.param(ShaclGenerator, {}, id="shaclgen"), + # ShExGenerator only emits RDF in this format; its default is ShExC text, + # where blank-node labelling does not apply. + pytest.param(ShExGenerator, {"format": "rdf"}, id="shexgen", marks=pytest.mark.network), + ] + + +@pytest.mark.parametrize(("generator", "kwargs"), _generator_cases()) +def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): + """Every RDF generator must actually apply the option, not merely accept it. + + Asserting only that the attribute exists would pass even if a generator + forgot to pass it down to :func:`canonicalize_rdf_graph`. Instead this + checks the observable consequence: RDFC-1.0 names blank nodes ``c14nN``, + while diff-stable labels are neighbourhood hashes, so a generator that + honours the flag emits no ``c14nN`` label at all. + """ + assert generator.diff_stable is False, f"{generator.__name__} must default to off" + + schema = tmp_path / "schema.yaml" + schema.write_text(_DIFF_STABLE_SCHEMA, encoding="utf-8", newline="\n") + + plain = generator(str(schema), **kwargs).serialize() + stable = generator(str(schema), diff_stable=True, **kwargs).serialize() + + # Guards the test itself: if the fixture stopped producing blank nodes the + # assertion below would hold vacuously. + assert re.search(r"c14n\d+", plain), f"{generator.__name__} output has no blank nodes to relabel" + assert not re.search(r"c14n\d+", stable), ( + f"{generator.__name__} still emits RDFC-1.0 blank-node labels with diff_stable=True; " + "the flag is probably not threaded into canonicalize_rdf_graph()" + ) From a55ba23124182ef94593ad26fdc33f00b67135a4 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 10:52:51 +0200 Subject: [PATCH 02/24] fix(rdf): bump diffable-rdf to 0.3.0 and stop diff_stable silently no-opping Bump the floor to diffable-rdf 0.3.0 and add the missing uv.lock entry: the dependency was declared in pyproject.toml but never locked, so "uv lock --check" and the "uv sync --frozen" anti-malware gate would both have failed CI. 0.3.0 also fixes two defects in the Weisfeiler-Lehman labelling this feature relies on. Disconnected blank-node components now converge independently, so an edit in one region no longer relabels an unrelated one. And the suffix used to tell structurally indistinguishable nodes apart was assigned in c14nN *text* order, so c14n10 sorted between c14n1 and c14n2 -- adding a tenth tied blank node relabelled eight of the nine already there, the exact opposite of what this labelling is for. Separately, diff_stable=True was silently ignored whenever pyoxigraph refused the graph and canonicalize_rdf_graph degraded to rdflib. Weisfeiler-Lehman refinement consumes canonical pyoxigraph quads, and that path exists precisely because there are none, so the argument could not be honoured -- but the caller was never told. "shaclgen --include-annotations --diff-stable" reaches it, via the literal predicate an annotation tag without a ':' produces, and returned output byte-identical to --no-diff-stable. It now warns, with a regression test asserting the warning and the byte-identical output that makes silence misleading. --- packages/linkml_runtime/pyproject.toml | 2 +- .../linkml_runtime/utils/rdf_canonicalize.py | 20 ++++++++++++++- .../test_utils/test_rdf_canonicalize.py | 25 +++++++++++++++++++ uv.lock | 15 +++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index b879dec7ee..ede7112692 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", - "diffable-rdf>=0.2.0", + "diffable-rdf>=0.3.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index e0fadae34a..8e28488d55 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -307,7 +307,8 @@ def canonicalize_rdf_graph( editing one part of a schema does not renumber unrelated blank nodes. Output is deterministic and isomorphic either way; only the choice of label changes. Off by default because enabling it relabels existing - output. + output. Has no effect on the rdflib fallback path (non-standard RDF), + which warns rather than silently ignoring the request. :return: Deterministic string serialization of the graph. """ ox_format = _FORMAT_MAP.get(output_format.lower()) @@ -338,6 +339,23 @@ def canonicalize_rdf_graph( RDFCanonicalizationWarning, stacklevel=2, ) + if diff_stable: + # Weisfeiler-Lehman refinement consumes canonical pyoxigraph quads, + # and this path exists precisely because pyoxigraph refused the + # graph, so there are none to refine. The fallback is deterministic + # but not diff-stable: say so rather than returning output that + # silently ignores the argument. ``shaclgen --include-annotations`` + # reaches this path, because an annotation tag without a ``:`` + # becomes a literal predicate. + warnings.warn( + "diff_stable=True was requested but this graph took the rdflib fallback, " + "which cannot apply Weisfeiler-Lehman blank-node labels. Output is " + "deterministic but NOT diff-stable: an unrelated edit may still renumber " + "blank nodes. Make the offending terms standard RDF (absolute IRIs, IRI " + "predicates) to get diff-stable labels.", + RDFCanonicalizationWarning, + stacklevel=2, + ) return _deterministic_fallback_serialize(graph, output_format) dataset = ox.Dataset() diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 0ca79c3c90..1bc07583af 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -642,3 +642,28 @@ def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): f"{generator.__name__} still emits RDFC-1.0 blank-node labels with diff_stable=True; " "the flag is probably not threaded into canonicalize_rdf_graph()" ) + + +def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback(): + """A request the fallback cannot honour must be reported, not ignored. + + ``wl_relabel_quads`` consumes canonical pyoxigraph quads, and the rdflib + fallback exists precisely because pyoxigraph refused the graph. Returning + the same bytes for ``diff_stable=True`` and ``diff_stable=False`` without + saying so lets a caller believe the output is diff-stable when it is not. + """ + graph = _make_graph_with_bnodes() + # A relative IRI is non-standard RDF, so pyoxigraph rejects the graph and + # canonicalize_rdf_graph degrades to rdflib -- the same path that + # ``shaclgen --include-annotations`` takes via its literal predicates. + graph.add((URIRef("testing"), URIRef("http://example.com/p"), Literal("v"))) + + with pytest.warns(RDFCanonicalizationWarning, match="NOT diff-stable"): + stable = canonicalize_rdf_graph(graph, diff_stable=True) + + with pytest.warns(RDFCanonicalizationWarning): + plain = canonicalize_rdf_graph(graph, diff_stable=False) + + # The warning is the contract: the bytes really are identical, which is + # exactly why staying silent would be misleading. + assert stable == plain diff --git a/uv.lock b/uv.lock index d8cddaa9e1..e862cfc897 100644 --- a/uv.lock +++ b/uv.lock @@ -1085,6 +1085,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "diffable-rdf" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyoxigraph" }, + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/f3/45771c536aea850f2884206349e35910bbabcaebeec890c5c0cc08b040f9/diffable_rdf-0.3.0.tar.gz", hash = "sha256:6fcf84d9323cd35fc75f423bd73836469cfad198ebb7e6ab86a8743724cd000d", size = 829395, upload-time = "2026-09-11T08:00:26.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/22/ef80dba96cd558f59d2ab225602bde318a925d437866379303e1f41a4e96/diffable_rdf-0.3.0-py3-none-any.whl", hash = "sha256:8756137423f9f28beacb5cd99ba8d66317328a0c936b1dbb4527831c7d9c6784", size = 43578, upload-time = "2026-09-11T08:00:24.542Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -2618,6 +2631,7 @@ dependencies = [ { name = "click" }, { name = "curies" }, { name = "deprecated" }, + { name = "diffable-rdf" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "json-flattener" }, @@ -2650,6 +2664,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'dev'" }, { name = "curies", specifier = ">=0.14.6" }, { name = "deprecated" }, + { name = "diffable-rdf", specifier = ">=0.3.0" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'", specifier = ">=0.7.2,<1.0.0" }, { name = "json-flattener", specifier = ">=0.1.9" }, From a4d52530c7f367e274ae86ad70d76dfa64afffa5 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 13:45:48 +0200 Subject: [PATCH 03/24] build(deps): require diffable-rdf 0.4.0 0.4.0 carries graph.base through the library's rdflib fallback, verifying that every absolute IRI of the source survives a re-read rather than dropping the directive outright, and adds a diff_stable parameter to canonicalize_rdf_graph. The lock entry is written by hand because the workspace sets exclude-newer = "7 days", which filters any release younger than that from resolution; 0.3.0 was pinned the same way for the same reason, and both become resolvable normally on 2026-09-18. uv lock --check and uv sync --all-groups both accept the entry. https://github.com/ASCS-eV/diffable-rdf/releases/tag/v0.4.0 --- packages/linkml_runtime/pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index ede7112692..c940eeac56 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", - "diffable-rdf>=0.3.0", + "diffable-rdf>=0.4.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] diff --git a/uv.lock b/uv.lock index e862cfc897..3c867304c8 100644 --- a/uv.lock +++ b/uv.lock @@ -1087,15 +1087,15 @@ wheels = [ [[package]] name = "diffable-rdf" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyoxigraph" }, { name = "rdflib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/f3/45771c536aea850f2884206349e35910bbabcaebeec890c5c0cc08b040f9/diffable_rdf-0.3.0.tar.gz", hash = "sha256:6fcf84d9323cd35fc75f423bd73836469cfad198ebb7e6ab86a8743724cd000d", size = 829395, upload-time = "2026-09-11T08:00:26.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/df/1d71c0c984eac1cfd25531aabf84528941b01083c875da49d44882d1b5af/diffable_rdf-0.4.0.tar.gz", hash = "sha256:a84afaa10332e6a039b6ddcabf76bced3049e4da467c88ee786d0fa35218111e", size = 838040, upload-time = "2026-09-11T11:43:12.844Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/22/ef80dba96cd558f59d2ab225602bde318a925d437866379303e1f41a4e96/diffable_rdf-0.3.0-py3-none-any.whl", hash = "sha256:8756137423f9f28beacb5cd99ba8d66317328a0c936b1dbb4527831c7d9c6784", size = 43578, upload-time = "2026-09-11T08:00:24.542Z" }, + { url = "https://files.pythonhosted.org/packages/7f/49/b62ee864fe6769b759f04e3709fd79711b1cdf49223b1bb0e985e507ead3/diffable_rdf-0.4.0-py3-none-any.whl", hash = "sha256:c59c57429465f786fdbc8d38ddbb687da16748c5752f0c2ac4a77b5527aa9aed", size = 46126, upload-time = "2026-09-11T11:43:11.536Z" }, ] [[package]] @@ -2664,7 +2664,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'dev'" }, { name = "curies", specifier = ">=0.14.6" }, { name = "deprecated" }, - { name = "diffable-rdf", specifier = ">=0.3.0" }, + { name = "diffable-rdf", specifier = ">=0.4.0" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'", specifier = ">=0.7.2,<1.0.0" }, { name = "json-flattener", specifier = ">=0.1.9" }, From 16172e93ed1deac08fc429995429d172684310f2 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Mon, 14 Sep 2026 11:19:02 +0200 Subject: [PATCH 04/24] test(rdf): mark diff-stable tests for diffable-rdf canary CI Registers a diffable_rdf pytest marker and applies it to the six diff_stable tests (including the parametrized per-generator test), so an external CI job can run 'pytest -m diffable_rdf' against a local diffable-rdf checkout without pulling in the rest of the suite. Follow-up to a review comment on this PR requesting canary tests from linkml running in diffable-rdf's own CI, modeled on the numpydantic project's tests-linkml.yml workflow. --- pyproject.toml | 1 + tests/linkml_runtime/test_utils/test_rdf_canonicalize.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 559cf265ac..fd62dc2986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,7 @@ markers = [ "yarrrml: End-to-end tests for the YARRRML generator", "typedbgen: Tests for the TypeDB generator", "arrays: Array specifications!", + "diffable_rdf: tests that exercise the diffable-rdf integration (--diff-stable); run as a canary against linkml in diffable-rdf's own CI", ] # https://docs.astral.sh/ruff/settings/ diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 1bc07583af..9fc94d38d4 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -529,12 +529,14 @@ def _changed_line_count(before: str, after: str) -> int: return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) +@pytest.mark.diffable_rdf def test_diff_stable_is_opt_in(): """The default must keep producing exactly the output it produced before.""" graph = _make_graph_with_bnodes() assert canonicalize_rdf_graph(graph) == canonicalize_rdf_graph(graph, diff_stable=False) +@pytest.mark.diffable_rdf def test_diff_stable_preserves_semantics(): """Relabelling blank nodes must not change what the graph means.""" graph = _make_graph_with_bnodes() @@ -547,6 +549,7 @@ def test_diff_stable_preserves_semantics(): assert rdflib.compare.isomorphic(plain, stable) +@pytest.mark.diffable_rdf def test_diff_stable_is_deterministic(): """Diff stability must not cost determinism, which is the stronger property.""" graph = _make_graph_with_bnodes() @@ -554,6 +557,7 @@ def test_diff_stable_is_deterministic(): assert len(outputs) == 1 +@pytest.mark.diffable_rdf def test_diff_stable_confines_an_insertion_to_the_lines_it_touches(): """Inserting one subject must not relabel the blank nodes of the others. @@ -617,6 +621,7 @@ def _generator_cases(): ] +@pytest.mark.diffable_rdf @pytest.mark.parametrize(("generator", "kwargs"), _generator_cases()) def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): """Every RDF generator must actually apply the option, not merely accept it. @@ -644,6 +649,7 @@ def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): ) +@pytest.mark.diffable_rdf def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback(): """A request the fallback cannot honour must be reported, not ignored. From 20ee81fb144fa8c6f666fa93fe7c02b4e42b1806 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 16 Sep 2026 07:19:42 +0200 Subject: [PATCH 05/24] Make diff-stable serialization optional and address review feedback Expose runtime and LinkML extras with a lazy import and an installation hint. Move usage guidance into the collaboration guide and shorten generator help. Assert designed RDF edits, source preservation, generator output and CLI flags. Check ordinary and extra wheel installs in the existing package build job. --- .github/tests/test_diff_stable_install.py | 147 ++++++++++ .github/workflows/main.yaml | 26 ++ docs/howtos/collaborative-development.md | 30 ++ packages/linkml/pyproject.toml | 4 + .../linkml/src/linkml/generators/owlgen.py | 20 +- .../linkml/src/linkml/generators/rdfgen.py | 20 +- .../linkml/src/linkml/generators/shaclgen.py | 20 +- .../linkml/src/linkml/generators/shexgen.py | 20 +- packages/linkml_runtime/pyproject.toml | 2 +- .../linkml_runtime/utils/rdf_canonicalize.py | 37 +-- tests/linkml/test_scripts/test_diff_stable.py | 99 +++++++ .../test_utils/test_rdf_canonicalize.py | 271 ++++++++++++------ uv.lock | 139 +++++---- 13 files changed, 585 insertions(+), 250 deletions(-) create mode 100644 .github/tests/test_diff_stable_install.py create mode 100644 tests/linkml/test_scripts/test_diff_stable.py diff --git a/.github/tests/test_diff_stable_install.py b/.github/tests/test_diff_stable_install.py new file mode 100644 index 0000000000..ef2927ccf4 --- /dev/null +++ b/.github/tests/test_diff_stable_install.py @@ -0,0 +1,147 @@ +"""Run each selection in a fresh environment containing the built wheels.""" + +import importlib +import re +import sys +import sysconfig +from importlib.metadata import metadata, version +from importlib.util import find_spec +from pathlib import Path + +import pytest +from packaging.requirements import Requirement + +_SCHEMA = """id: https://example.org/install +name: install +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/install/ +default_prefix: ex +imports: [linkml:types] +classes: + Thing: + attributes: + label: + range: string +""" + + +def _assert_installed(package: str) -> None: + """Require imports from this environment's installed package directory.""" + module = importlib.import_module(package) + assert Path(module.__file__).resolve().is_relative_to(Path(sysconfig.get_path("purelib")).resolve()) + + +def _assert_runtime_requirements() -> None: + """Check ordinary compatibility and the extra's forwarding in wheel metadata.""" + requirements = [Requirement(value) for value in metadata("linkml").get_all("Requires-Dist")] + (ordinary,) = [r for r in requirements if r.name == "linkml-runtime" and r.marker is None] + (forwarded,) = [r for r in requirements if r.name == "linkml-runtime" and r.marker is not None] + assert ordinary.extras == set() + assert forwarded.extras == {"diff-stable"} + assert forwarded.marker.evaluate({"extra": "diff-stable"}) + assert not forwarded.marker.evaluate({"extra": ""}) + assert ordinary.specifier == forwarded.specifier + assert ordinary.specifier.contains(version("linkml-runtime"), prereleases=True) + + +def _assert_diff_stable_labels(output: str) -> None: + """Require actual blank-node terms with the enabled label syntax.""" + import pyoxigraph as ox + + quads = ox.parse(output, format=ox.RdfFormat.TURTLE) + nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} + assert nodes + assert all(re.fullmatch(r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", node.value) for node in nodes) + + +def test_runtime_without_extra() -> None: + """Ordinary runtime installs work and explain how to enable the option.""" + assert find_spec("diffable_rdf") is None + _assert_installed("linkml_runtime") + from rdflib import Graph + from rdflib.compare import isomorphic + + from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + + graph = Graph().parse(data=' [ "v" ] .') + for kwargs in ({}, {"diff_stable": False}): + output = canonicalize_rdf_graph(graph, **kwargs) + assert isomorphic(graph, Graph().parse(data=output, format="turtle")) + assert "diffable_rdf" not in sys.modules + with pytest.raises(ImportError, match=r"linkml-runtime\[diff-stable\]"): + canonicalize_rdf_graph(graph, diff_stable=True) + + +def test_linkml_without_extra(tmp_path: Path) -> None: + """Generator imports, help and default output do not require the extra.""" + assert find_spec("diffable_rdf") is None + _assert_installed("linkml_runtime") + _assert_installed("linkml") + _assert_runtime_requirements() + from click.testing import CliRunner + from rdflib import Graph + from rdflib.namespace import RDF, SH + + from linkml.generators.shaclgen import cli + + for name in ("owlgen", "rdfgen", "shaclgen", "shexgen"): + command = importlib.import_module(f"linkml.generators.{name}").cli + result = CliRunner().invoke(command, ["--help"]) + assert result.exit_code == 0, result.output + schema = tmp_path / "schema.yaml" + schema.write_text(_SCHEMA, encoding="utf-8") + result = CliRunner().invoke(cli, [str(schema)]) + assert result.exit_code == 0, result.output + graph = Graph().parse(data=result.stdout, format="turtle") + assert list(graph.subjects(RDF.type, SH.NodeShape)) + assert "diffable_rdf" not in sys.modules + result = CliRunner().invoke(cli, [str(schema), "--diff-stable"]) + assert result.exit_code != 0 + assert isinstance(result.exception, ImportError) + assert "linkml[diff-stable]" in str(result.exception) + + +def test_runtime_with_extra() -> None: + """The runtime extra supplies the relabeler and preserves the source graph.""" + assert find_spec("diffable_rdf") is not None + _assert_installed("linkml_runtime") + from rdflib import Graph + from rdflib.compare import isomorphic + + from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + + graph = Graph().parse(data=' [ "v" ] .') + canonicalize_rdf_graph(graph) + assert "diffable_rdf" not in sys.modules + output = canonicalize_rdf_graph(graph, output_format="nt", diff_stable=True) + parsed = Graph().parse(data=output, format="nt") + assert len(parsed) == len(graph) + assert isomorphic(graph, parsed) + _assert_diff_stable_labels(output) + _assert_installed("diffable_rdf") + + +def test_linkml_with_extra(tmp_path: Path) -> None: + """The LinkML extra installs the relabeler through the runtime extra.""" + assert find_spec("diffable_rdf") is not None + _assert_installed("linkml_runtime") + _assert_installed("linkml") + _assert_runtime_requirements() + from click.testing import CliRunner + from rdflib import Graph, Namespace + from rdflib.namespace import RDF, SH + + from linkml.generators.shaclgen import cli + + schema = tmp_path / "schema.yaml" + schema.write_text(_SCHEMA, encoding="utf-8") + result = CliRunner().invoke(cli, [str(schema), "--diff-stable"]) + assert result.exit_code == 0, result.output + graph = Graph().parse(data=result.stdout, format="turtle") + ex = Namespace("https://example.org/install/") + assert (ex.Thing, RDF.type, SH.NodeShape) in graph + assert (ex.Thing, SH.targetClass, ex.Thing) in graph + assert {graph.value(node, SH.path) for node in graph.objects(ex.Thing, SH.property)} == {ex.label} + _assert_diff_stable_labels(result.stdout) + _assert_installed("diffable_rdf") diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 85c7aa330d..c2ec256ea6 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -237,3 +237,29 @@ jobs: version: ${{ env.UV_VERSION }} - name: Build source and wheel archives run: uv build --all-packages + - name: Test optional diff-stable installations + shell: bash + run: | + shopt -s nullglob + runtime_wheels=(dist/linkml_runtime-*.whl) + linkml_wheels=(dist/linkml-*.whl) + test "${#runtime_wheels[@]}" -eq 1 + test "${#linkml_wheels[@]}" -eq 1 + for scenario in runtime_without_extra linkml_without_extra runtime_with_extra linkml_with_extra; do + scenario_env="$RUNNER_TEMP/$scenario" + uv venv --python 3.13 "$scenario_env" + packages=("${runtime_wheels[0]}" pytest) + case "$scenario" in + runtime_with_extra) packages=("${runtime_wheels[0]}[diff-stable]" pytest) ;; + linkml_without_extra) packages+=("${linkml_wheels[0]}") ;; + linkml_with_extra) packages+=("${linkml_wheels[0]}[diff-stable]") ;; + esac + uv pip install --python "$scenario_env/bin/python" "${packages[@]}" + uv pip check --python "$scenario_env/bin/python" + ( + cd "$RUNNER_TEMP" + uv run --no-project "$scenario_env/bin/python" -I -m pytest \ + --noconftest --import-mode=importlib \ + "$GITHUB_WORKSPACE/.github/tests/test_diff_stable_install.py" -k "$scenario" -v + ) + done diff --git a/docs/howtos/collaborative-development.md b/docs/howtos/collaborative-development.md index 94515df2d1..221337e843 100644 --- a/docs/howtos/collaborative-development.md +++ b/docs/howtos/collaborative-development.md @@ -40,6 +40,36 @@ Some projects may opt to manage their data alongside the schema in the same repo for "registry" style projects, but in other cases the data may be best managed separately in a dedicated store or a repository like Zenodo or Figshare. +(rdf-in-version-control)= +### Keep generated RDF diffs small + +When you track generated RDF in Git, adding a class or property can change many +blank-node labels. The default RDFC-1.0 numbering is repeatable for the same graph, +but an insertion can renumber existing nodes and obscure the actual schema edit. + +The optional `--diff-stable` flag uses +[diffable-rdf](https://github.com/ASCS-eV/diffable-rdf) to reduce this label churn. +In a uv project containing your schema, install LinkML with the extra and generate +SHACL as follows (using a release that includes this option): + +```bash +uv add 'linkml[diff-stable]' +uv run gen-shacl --diff-stable schema.yaml > schema.shacl.ttl +``` + +The flag also works with `gen-owl`, `gen-rdf`, and `gen-shex --format rdf`. +Python callers of `canonicalize_rdf_graph(..., diff_stable=True)` can install +`linkml-runtime[diff-stable]`. The feature is disabled by default; enabling it +changes existing labels once, so keep that regeneration separate from schema edits. + +Labels depend on blank-node neighborhoods. Edits within connected structures or +among symmetric nodes can still affect other labels; minimum diffs are not +guaranteed. Keep tool versions consistent when comparing generated files. +The relabeled output is not the standard RDFC canonical representation. +ShExC, ShEx JSON and instance-data conversion are outside this option's scope. +Non-standard RDF takes the existing fallback path, which warns that it cannot +apply diff-stable labels. + ### Permissively License Your Code and Data (adapted from [O3 guidelines](https://osf.io/vuzt3/)) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index a0e778f2a4..067281b47f 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -68,6 +68,9 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "platformdirs>=4.5.0", ] +[project.optional-dependencies] +diff-stable = ["linkml-runtime[diff-stable]>=1.10.0,<2.0.0"] + [dependency-groups] lint = [ "black >= 24.0.0", @@ -79,6 +82,7 @@ shacl = [ "pyshacl>=0.40.1", ] tests = [ + "linkml-runtime[diff-stable]", { include-group = "lint" }, { include-group = "typing" }, { include-group = "shacl" }, diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 7751eab34d..717fbb31aa 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -123,20 +123,7 @@ class OwlSchemaGenerator(Generator): # ObjectVars diff_stable: bool = False - """Label blank nodes so that unrelated edits leave them untouched. - - Output is already deterministic: RDFC-1.0 guarantees that isomorphic - graphs serialize identically. It does not guarantee that *similar* - graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a - global order, so adding one class can renumber every blank node after - it and rewrite most of the file. - - When ``True``, blank-node labels are instead derived from each node's - own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels - only the blank nodes it actually touches. The output stays - deterministic and isomorphic either way; only the choice of label - changes. Off by default because enabling it relabels existing output. - """ + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" metadata_profile: MetadataProfile | None = None """Deprecated - use metadata_profiles.""" @@ -1865,9 +1852,8 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef: default=False, show_default=True, help=( - "Derive blank-node labels from each node's own neighbourhood so that " - "unrelated edits leave them unchanged. Output is deterministic either " - "way; this makes successive versions of a file diff cleanly." + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" ), ) @click.version_option(__version__, "-V", "--version") diff --git a/packages/linkml/src/linkml/generators/rdfgen.py b/packages/linkml/src/linkml/generators/rdfgen.py index 052fcff12e..5b8f8dc1c7 100644 --- a/packages/linkml/src/linkml/generators/rdfgen.py +++ b/packages/linkml/src/linkml/generators/rdfgen.py @@ -79,20 +79,7 @@ class RDFGenerator(Generator): # ObjectVars diff_stable: bool = False - """Label blank nodes so that unrelated edits leave them untouched. - - Output is already deterministic: RDFC-1.0 guarantees that isomorphic - graphs serialize identically. It does not guarantee that *similar* - graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a - global order, so adding one class can renumber every blank node after - it and rewrite most of the file. - - When ``True``, blank-node labels are instead derived from each node's - own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels - only the blank nodes it actually touches. The output stays - deterministic and isomorphic either way; only the choice of label - changes. Off by default because enabling it relabels existing output. - """ + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" emit_metadata: bool = False context: list[str] = None @@ -158,9 +145,8 @@ def end_schema(self, output: str | None = None, context: str = None, **_) -> str default=False, show_default=True, help=( - "Derive blank-node labels from each node's own neighbourhood so that " - "unrelated edits leave them unchanged. Output is deterministic either " - "way; this makes successive versions of a file diff cleanly." + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" ), ) @click.version_option(__version__, "-V", "--version") diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 5d42fea272..1804b1204e 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -143,20 +143,7 @@ class ShaclGenerator(Generator): """ diff_stable: bool = False - """Label blank nodes so that unrelated edits leave them untouched. - - Output is already deterministic: RDFC-1.0 guarantees that isomorphic - graphs serialize identically. It does not guarantee that *similar* - graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a - global order, so adding one class can renumber every blank node after - it and rewrite most of the file. - - When ``True``, blank-node labels are instead derived from each node's - own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels - only the blank nodes it actually touches. The output stays - deterministic and isomorphic either way; only the choice of label - changes. Off by default because enabling it relabels existing output. - """ + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" emit_rules: bool = True """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. @@ -950,9 +937,8 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: default=False, show_default=True, help=( - "Derive blank-node labels from each node's own neighbourhood so that " - "unrelated edits leave them unchanged. Output is deterministic either " - "way; this makes successive versions of a file diff cleanly." + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" ), ) @click.version_option(__version__, "-V", "--version") diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py index 7c43b13bc0..e74e45225e 100644 --- a/packages/linkml/src/linkml/generators/shexgen.py +++ b/packages/linkml/src/linkml/generators/shexgen.py @@ -41,20 +41,7 @@ class ShExGenerator(Generator): # ObjectVars diff_stable: bool = False - """Label blank nodes so that unrelated edits leave them untouched. - - Output is already deterministic: RDFC-1.0 guarantees that isomorphic - graphs serialize identically. It does not guarantee that *similar* - graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a - global order, so adding one class can renumber every blank node after - it and rewrite most of the file. - - When ``True``, blank-node labels are instead derived from each node's - own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels - only the blank nodes it actually touches. The output stays - deterministic and isomorphic either way; only the choice of label - changes. Off by default because enabling it relabels existing output. - """ + """Reduce blank-node label churn in RDF output. See :ref:`rdf-in-version-control`.""" shex: Schema = field(default_factory=lambda: Schema()) # ShEx Schema being generated shapes: list = field(default_factory=lambda: []) @@ -279,9 +266,8 @@ def _get_subproperty_values(self, slot: SlotDefinition) -> list: default=False, show_default=True, help=( - "Derive blank-node labels from each node's own neighbourhood so that " - "unrelated edits leave them unchanged. Output is deterministic either " - "way; this makes successive versions of a file diff cleanly." + "Reduce blank-node label churn across edits (--format rdf). See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" ), ) @click.version_option(__version__, "-V", "--version") diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index c940eeac56..8dfa18f4b2 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,7 +48,6 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", - "diffable-rdf>=0.4.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] @@ -63,6 +62,7 @@ dev = [ linkml-normalize = "linkml_runtime.processing.referencevalidator:cli" [project.optional-dependencies] +diff-stable = ["diffable-rdf>=0.4.0"] dev = [ "coverage", "requests-cache>=1.3.3", diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index 8e28488d55..d7a894be17 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -39,10 +39,10 @@ import io import re import warnings +from importlib.util import find_spec import pyoxigraph as ox import rdflib -from diffable_rdf import wl_relabel_quads from rdflib.compare import to_canonical_graph @@ -302,15 +302,20 @@ def canonicalize_rdf_graph( :param graph: The rdflib Graph to serialize. :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). - :param diff_stable: Derive blank-node labels from each node's own - neighbourhood instead of RDFC-1.0's global ``c14nN`` numbering, so that - editing one part of a schema does not renumber unrelated blank nodes. - Output is deterministic and isomorphic either way; only the choice of - label changes. Off by default because enabling it relabels existing - output. Has no effect on the rdflib fallback path (non-standard RDF), - which warns rather than silently ignoring the request. + :param diff_stable: Use diffable-rdf labels to reduce blank-node churn across + edits. Requires the ``diff-stable`` extra; disabled by default. The + non-standard RDF fallback warns when it cannot apply these labels. :return: Deterministic string serialization of the graph. + :raises ImportError: If diff-stable labels are requested without diffable-rdf. """ + if diff_stable: + if find_spec("diffable_rdf") is None: + raise ImportError( + "diff_stable=True requires diffable-rdf. Install " + "'linkml-runtime[diff-stable]' or 'linkml[diff-stable]'." + ) + from diffable_rdf import wl_relabel_quads + ox_format = _FORMAT_MAP.get(output_format.lower()) if ox_format is None: warnings.warn( @@ -340,13 +345,6 @@ def canonicalize_rdf_graph( stacklevel=2, ) if diff_stable: - # Weisfeiler-Lehman refinement consumes canonical pyoxigraph quads, - # and this path exists precisely because pyoxigraph refused the - # graph, so there are none to refine. The fallback is deterministic - # but not diff-stable: say so rather than returning output that - # silently ignores the argument. ``shaclgen --include-annotations`` - # reaches this path, because an annotation tag without a ``:`` - # becomes a literal predicate. warnings.warn( "diff_stable=True was requested but this graph took the rdflib fallback, " "which cannot apply Weisfeiler-Lehman blank-node labels. Output is " @@ -367,15 +365,6 @@ def canonicalize_rdf_graph( quads = list(dataset) - # 3b. Optionally re-label blank nodes with locality-sensitive hashes. - # RDFC-1.0 guarantees that identical graphs get identical labels, but it - # does not guarantee that *similar* graphs get similar labels: the labels - # are assigned by a global ordering, so inserting one blank node can - # renumber every label after it and turn a one-line semantic change into a - # whole-file diff. Weisfeiler-Lehman labels are derived only from each - # node's local neighbourhood, so unrelated regions keep their labels. - # Output stays deterministic and isomorphic either way; this only changes - # which label each blank node receives. if diff_stable: quads = wl_relabel_quads(quads) diff --git a/tests/linkml/test_scripts/test_diff_stable.py b/tests/linkml/test_scripts/test_diff_stable.py new file mode 100644 index 0000000000..cd072aed38 --- /dev/null +++ b/tests/linkml/test_scripts/test_diff_stable.py @@ -0,0 +1,99 @@ +"""CLI integration checks for optional diff-stable RDF labels.""" + +import re +from pathlib import Path + +import click +import pyoxigraph as ox +import pytest +from click.testing import CliRunner +from rdflib import Graph, Namespace, URIRef +from rdflib.namespace import OWL, RDF, SH + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.owlgen import cli as owl_cli +from linkml.generators.rdfgen import RDFGenerator +from linkml.generators.rdfgen import cli as rdf_cli +from linkml.generators.shaclgen import ShaclGenerator +from linkml.generators.shaclgen import cli as shacl_cli +from linkml.generators.shexgen import ShExGenerator +from linkml.generators.shexgen import cli as shex_cli +from linkml.utils.generator import Generator + + +@pytest.mark.parametrize( + ("command", "args", "rdf_type"), + [ + pytest.param(owl_cli, [], OWL.Class, id="owlgen"), + pytest.param( + rdf_cli, [], URIRef("https://w3id.org/linkml/ClassDefinition"), id="rdfgen", marks=pytest.mark.network + ), + pytest.param(shacl_cli, [], SH.NodeShape, id="shaclgen"), + pytest.param( + shex_cli, + ["--format", "rdf"], + URIRef("http://www.w3.org/ns/shex#Shape"), + id="shexgen", + marks=pytest.mark.network, + ), + ], +) +@pytest.mark.parametrize( + ("flag", "label_pattern"), + [ + pytest.param("--no-diff-stable", r"c14n[0-9]+", id="disabled"), + pytest.param("--diff-stable", r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", id="enabled"), + ], +) +def test_diff_stable_cli( + tmp_path: Path, command: click.Command, args: list[str], rdf_type: URIRef, flag: str, label_pattern: str +) -> None: + """Every RDF CLI produces the requested labels and the expected class.""" + schema = tmp_path / "schema.yaml" + schema.write_text( + """id: https://example.org/cli +name: cli +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/cli/ +default_prefix: ex +imports: [linkml:types] +classes: + Thing: + attributes: + label: + range: string +""", + encoding="utf-8", + ) + result = CliRunner().invoke(command, [str(schema), flag, *args]) + assert result.exit_code == 0, result.output + quads = list(ox.parse(result.stdout, format=ox.RdfFormat.TURTLE)) + blank_nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} + assert blank_nodes + assert all(re.fullmatch(label_pattern, node.value) for node in blank_nodes) + graph = Graph().parse(data=result.stdout, format="turtle") + assert (Namespace("https://example.org/cli/").Thing, RDF.type, rdf_type) in graph + + +@pytest.mark.parametrize( + ("generator", "command"), + [ + pytest.param(OwlSchemaGenerator, owl_cli, id="owlgen"), + pytest.param(RDFGenerator, rdf_cli, id="rdfgen"), + pytest.param(ShaclGenerator, shacl_cli, id="shaclgen"), + pytest.param(ShExGenerator, shex_cli, id="shexgen"), + ], +) +def test_diff_stable_defaults_and_help(generator: type[Generator], command: click.Command) -> None: + """Defaults are off and terminal help points to the shared guide.""" + assert generator.diff_stable is False + (option,) = [param for param in command.params if param.name == "diff_stable"] + assert option.default is False + result = CliRunner().invoke(command, ["--help"]) + assert result.exit_code == 0 + assert "--diff-stable / --no-diff-stable" in result.output + assert "[default: no-diff-stable]" in result.output + assert "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" in "".join( + result.output.split() + ) diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 9fc94d38d4..c3c935a188 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -1,17 +1,25 @@ """Tests for deterministic RDF serialization via pyoxigraph RDFC-1.0.""" +import difflib +import inspect import os import re import subprocess import sys import textwrap +from pathlib import Path import pyoxigraph as ox import pytest import rdflib -from rdflib import BNode, Graph, Literal, URIRef -from rdflib.namespace import RDF - +from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib.namespace import OWL, RDF, RDFS, SH, XSD + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.rdfgen import RDFGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml.generators.shexgen import ShExGenerator +from linkml.utils.generator import Generator from linkml_runtime.utils import rdf_canonicalize as rdf_canon_mod from linkml_runtime.utils.rdf_canonicalize import ( RDFCanonicalizationWarning, @@ -227,7 +235,8 @@ def test_curie_in_literal_with_hash_not_treated_as_comment(): assert "ex:bar\\. more" in str(obj) -def test_sort_is_load_bearing(): +@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) +def test_sort_is_load_bearing(diff_stable: bool) -> None: """Output is byte-identical across subprocesses with different PYTHONHASHSEED values. RDFC-1.0 stabilizes blank-node labels, but pyoxigraph's ``Dataset`` iteration @@ -240,6 +249,8 @@ def test_sort_is_load_bearing(): """ program = textwrap.dedent( """ + import sys + from rdflib import BNode, Graph, Literal, URIRef from rdflib.namespace import RDF from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph @@ -255,7 +266,7 @@ def test_sort_is_load_bearing(): g.add((URIRef("http://example.com/x"), URIRef("http://example.com/has"), bn)) g.add((bn, URIRef("http://example.com/q"), Literal(f"bn_{i}"))) - print(canonicalize_rdf_graph(g, output_format="turtle"), end="") + print(canonicalize_rdf_graph(g, output_format="turtle", diff_stable=sys.argv[1] == "True"), end="") """ ) @@ -264,7 +275,7 @@ def run(seed: str) -> str: # wholesale drops SystemRoot on Windows, which breaks winsock init. env = {**os.environ, "PYTHONHASHSEED": seed} result = subprocess.run( - [sys.executable, "-c", program], + [sys.executable, "-c", program, str(diff_stable)], check=True, capture_output=True, text=True, @@ -523,61 +534,120 @@ def _shapes_graph(count: int, extra: bool = False) -> Graph: def _changed_line_count(before: str, after: str) -> int: - import difflib + """Count added and removed content lines in a unified diff.""" diff = difflib.unified_diff(before.splitlines(), after.splitlines(), n=0, lineterm="") return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) -@pytest.mark.diffable_rdf -def test_diff_stable_is_opt_in(): - """The default must keep producing exactly the output it produced before.""" - graph = _make_graph_with_bnodes() - assert canonicalize_rdf_graph(graph) == canonicalize_rdf_graph(graph, diff_stable=False) +def test_diff_stable_is_opt_in() -> None: + """Diff-stable labels require an explicit request.""" + assert inspect.signature(canonicalize_rdf_graph).parameters["diff_stable"].default is False -@pytest.mark.diffable_rdf -def test_diff_stable_preserves_semantics(): - """Relabelling blank nodes must not change what the graph means.""" +@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) +@pytest.mark.parametrize("output_format", ["nt", "turtle"]) +def test_diff_stable_preserves_semantics(diff_stable: bool, output_format: str) -> None: + """Each serialization preserves the source graph under blank-node renaming.""" graph = _make_graph_with_bnodes() - - plain = rdflib.Graph() - plain.parse(data=canonicalize_rdf_graph(graph), format="turtle") - stable = rdflib.Graph() - stable.parse(data=canonicalize_rdf_graph(graph, diff_stable=True), format="turtle") - - assert rdflib.compare.isomorphic(plain, stable) + output = canonicalize_rdf_graph(graph, output_format=output_format, diff_stable=diff_stable) + parsed = Graph().parse(data=output, format=output_format) + assert len(parsed) == len(graph) + assert rdflib.compare.isomorphic(graph, parsed) @pytest.mark.diffable_rdf -def test_diff_stable_is_deterministic(): - """Diff stability must not cost determinism, which is the stronger property.""" - graph = _make_graph_with_bnodes() - outputs = {canonicalize_rdf_graph(graph, diff_stable=True) for _ in range(5)} - assert len(outputs) == 1 +def test_diff_stable_is_deterministic() -> None: + """A cycle's tied signatures remain independent of input blank-node IDs.""" + graphs = [] + predicate = URIRef("http://example.com/next") + for labels, reverse in [("abcd", False), ("dbac", True)]: + nodes = [BNode(label) for label in labels] + triples = [(node, predicate, nodes[(i + 1) % len(nodes)]) for i, node in enumerate(nodes)] + graph = Graph() + for triple in reversed(triples) if reverse else triples: + graph.add(triple) + graphs.append(graph) + + assert rdflib.compare.isomorphic(*graphs) + outputs = [canonicalize_rdf_graph(graph, output_format="nt", diff_stable=True) for graph in graphs] + assert outputs[0] == outputs[1] + for graph, output in zip(graphs, outputs): + assert rdflib.compare.isomorphic(graph, Graph().parse(data=output, format="nt")) + + +@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) +def test_diff_stable_insertion_labels(diff_stable: bool) -> None: + """An independent insertion renumbers RDFC labels but preserves the old WL label.""" + before = Graph().parse( + data="""@prefix ex: . + ex:Shape00 ex:property _:old . + _:old ex:path ex:p00 . + """, + format="turtle", + ) + after = Graph() + for triple in before: + after.add(triple) + inserted = BNode() + ex = Namespace("http://example.com/") + after.add((ex.ShapeAAAinserted, ex.property, inserted)) + after.add((inserted, ex.path, ex.pAAAinserted)) + + outputs = [canonicalize_rdf_graph(graph, output_format="nt", diff_stable=diff_stable) for graph in (before, after)] + parsed = [set(ox.parse(output, format=ox.RdfFormat.N_TRIPLES)) for output in outputs] + old_subject = ox.NamedNode(str(ex.Shape00)) + new_subject = ox.NamedNode(str(ex.ShapeAAAinserted)) + property_iri = ox.NamedNode(str(ex.property)) + path_iri = ox.NamedNode(str(ex.path)) + (old_before,) = {q.object for q in parsed[0] if q.subject == old_subject and q.predicate == property_iri} + (old_after,) = {q.object for q in parsed[1] if q.subject == old_subject and q.predicate == property_iri} + (new_after,) = {q.object for q in parsed[1] if q.subject == new_subject and q.predicate == property_iri} + assert all(isinstance(node, ox.BlankNode) for node in (old_before, old_after, new_after)) + assert old_after != new_after + inserted_quads = { + ox.Quad(new_subject, property_iri, new_after), + ox.Quad(new_after, path_iri, ox.NamedNode(str(ex.pAAAinserted))), + } + assert parsed[0] == { + ox.Quad(old_subject, property_iri, old_before), + ox.Quad(old_before, path_iri, ox.NamedNode(str(ex.p00))), + } + assert ( + parsed[1] + == { + ox.Quad(old_subject, property_iri, old_after), + ox.Quad(old_after, path_iri, ox.NamedNode(str(ex.p00))), + } + | inserted_quads + ) + + if diff_stable: + assert all(re.fullmatch(_STABLE_LABEL, node.value) for node in (old_before, old_after, new_after)) + assert old_before == old_after + assert parsed[1] - parsed[0] == inserted_quads + assert parsed[0] - parsed[1] == set() + else: + assert (old_before.value, old_after.value, new_after.value) == ("c14n0", "c14n1", "c14n0") + for graph, output in zip((before, after), outputs): + assert rdflib.compare.isomorphic(graph, Graph().parse(data=output, format="nt")) @pytest.mark.diffable_rdf -def test_diff_stable_confines_an_insertion_to_the_lines_it_touches(): - """Inserting one subject must not relabel the blank nodes of the others. - - RDFC-1.0 numbers blank nodes ``c14nN`` in a global order, so a subject - sorting before the others shifts every subsequent label and rewrites - most of the file. This is the entire reason the option exists, so the - assertion is on the *ratio*, not on an absolute line count that would - be brittle across rdflib versions. - """ +def test_diff_stable_confines_an_insertion_to_the_lines_it_touches() -> None: + """Diff-stable labels reduce Turtle line churn for independent property nodes.""" before, after = _shapes_graph(20), _shapes_graph(20, extra=True) - baseline = _changed_line_count(canonicalize_rdf_graph(before), canonicalize_rdf_graph(after)) stable = _changed_line_count( canonicalize_rdf_graph(before, diff_stable=True), canonicalize_rdf_graph(after, diff_stable=True), ) - assert stable < baseline / 4, f"expected diff-stable output to churn far less; got {stable} vs baseline {baseline}" +_STABLE_LABEL = r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?" + + _DIFF_STABLE_SCHEMA = """\ id: https://example.org/diffstable name: diffstable @@ -602,74 +672,87 @@ def test_diff_stable_confines_an_insertion_to_the_lines_it_touches(): """ -def _generator_cases(): - """The four generators that serialize RDF, with the args that make them do so.""" - from linkml.generators.owlgen import OwlSchemaGenerator - from linkml.generators.rdfgen import RDFGenerator - from linkml.generators.shaclgen import ShaclGenerator - from linkml.generators.shexgen import ShExGenerator +@pytest.fixture +def diff_stable_schema(tmp_path: Path) -> Path: + """Write the small schema for real generator calls.""" + schema = tmp_path / "schema.yaml" + schema.write_text(_DIFF_STABLE_SCHEMA, encoding="utf-8") + return schema - return [ + +@pytest.mark.diffable_rdf +@pytest.mark.parametrize( + ("generator", "kwargs"), + [ pytest.param(OwlSchemaGenerator, {}, id="owlgen"), - # rdfgen and shexgen resolve JSON-LD contexts (linkml types, shex.jsonld); - # the `network` marker serves those from local stubs. See tests/conftest.py. pytest.param(RDFGenerator, {}, id="rdfgen", marks=pytest.mark.network), pytest.param(ShaclGenerator, {}, id="shaclgen"), - # ShExGenerator only emits RDF in this format; its default is ShExC text, - # where blank-node labelling does not apply. pytest.param(ShExGenerator, {"format": "rdf"}, id="shexgen", marks=pytest.mark.network), - ] - - -@pytest.mark.diffable_rdf -@pytest.mark.parametrize(("generator", "kwargs"), _generator_cases()) -def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): - """Every RDF generator must actually apply the option, not merely accept it. - - Asserting only that the attribute exists would pass even if a generator - forgot to pass it down to :func:`canonicalize_rdf_graph`. Instead this - checks the observable consequence: RDFC-1.0 names blank nodes ``c14nN``, - while diff-stable labels are neighbourhood hashes, so a generator that - honours the flag emits no ``c14nN`` label at all. - """ - assert generator.diff_stable is False, f"{generator.__name__} must default to off" - - schema = tmp_path / "schema.yaml" - schema.write_text(_DIFF_STABLE_SCHEMA, encoding="utf-8", newline="\n") - - plain = generator(str(schema), **kwargs).serialize() - stable = generator(str(schema), diff_stable=True, **kwargs).serialize() - - # Guards the test itself: if the fixture stopped producing blank nodes the - # assertion below would hold vacuously. - assert re.search(r"c14n\d+", plain), f"{generator.__name__} output has no blank nodes to relabel" - assert not re.search(r"c14n\d+", stable), ( - f"{generator.__name__} still emits RDFC-1.0 blank-node labels with diff_stable=True; " - "the flag is probably not threaded into canonicalize_rdf_graph()" - ) + ], +) +def test_diff_stable_reaches_every_rdf_generator( + diff_stable_schema: Path, generator: type[Generator], kwargs: dict[str, str] +) -> None: + """Every RDF generator emits the designed schema with diff-stable labels.""" + output = generator(str(diff_stable_schema), diff_stable=True, **kwargs).serialize() + quads = list(ox.parse(output, format=ox.RdfFormat.TURTLE)) + blank_nodes = {term for quad in quads for term in (quad.subject, quad.object) if isinstance(term, ox.BlankNode)} + assert blank_nodes + assert all(re.fullmatch(_STABLE_LABEL, node.value) for node in blank_nodes) + graph = Graph().parse(data=output, format="turtle") + ex = Namespace("https://example.org/diffstable/") + if generator is OwlSchemaGenerator: + assert (ex.Person, RDF.type, OWL.Class) in graph + assert (ex.Organization, RDF.type, OWL.Class) in graph + (restriction,) = { + node + for node in graph.objects(ex.Person, RDFS.subClassOf) + if (node, OWL.onProperty, ex.knows) in graph and (node, OWL.allValuesFrom, ex.Person) in graph + } + assert isinstance(restriction, BNode) + assert (restriction, RDF.type, OWL.Restriction) in graph + assert (restriction, OWL.allValuesFrom, ex.Person) in graph + elif generator is ShaclGenerator: + for subject, paths in [(ex.Person, {ex.name, ex.knows}), (ex.Organization, {ex.name})]: + assert (subject, RDF.type, SH.NodeShape) in graph + assert (subject, SH.targetClass, subject) in graph + properties = set(graph.objects(subject, SH.property)) + assert {graph.value(prop, SH.path) for prop in properties} == paths + (ignored,) = graph.objects(subject, SH.ignoredProperties) + assert set(graph.predicate_objects(ignored)) == {(RDF.first, RDF.type), (RDF.rest, RDF.nil)} + (knows,) = {node for node in graph.objects(ex.Person, SH.property) if (node, SH.path, ex.knows) in graph} + assert (knows, SH["class"], ex.Person) in graph + elif generator is RDFGenerator: + linkml = Namespace("https://w3id.org/linkml/") + schema = ex.diffstable + assert (schema, RDF.type, linkml.SchemaDefinition) in graph + assert set(graph.objects(schema, linkml.classes)) == {ex.Person, ex.Organization} + (prefix,) = {node for node in graph.objects(schema, SH.declare) if (node, SH.prefix, Literal("ex")) in graph} + assert (prefix, SH.namespace, Literal(str(ex), datatype=XSD.anyURI)) in graph + else: + assert generator is ShExGenerator + shex = Namespace("http://www.w3.org/ns/shex#") + assert (ex.Person, RDF.type, shex.Shape) in graph + (expression,) = graph.objects(ex.Person, shex.expression) + assert (expression, RDF.type, shex.EachOf) in graph + (expressions,) = graph.objects(expression, shex.expressions) + assert ex.Person_tes in set(graph.items(expressions)) + assert (ex.Person_tes, RDF.type, shex.EachOf) in graph + (slots,) = graph.objects(ex.Person_tes, shex.expressions) + (knows,) = {node for node in graph.items(slots) if (node, shex.predicate, ex.knows) in graph} + assert (knows, RDF.type, shex.TripleConstraint) in graph + assert (knows, shex.valueExpr, ex.Person) in graph + assert (knows, shex.min, Literal(0)) in graph + assert (knows, shex.max, Literal(-1)) in graph @pytest.mark.diffable_rdf -def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback(): - """A request the fallback cannot honour must be reported, not ignored. - - ``wl_relabel_quads`` consumes canonical pyoxigraph quads, and the rdflib - fallback exists precisely because pyoxigraph refused the graph. Returning - the same bytes for ``diff_stable=True`` and ``diff_stable=False`` without - saying so lets a caller believe the output is diff-stable when it is not. - """ +def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback() -> None: + """A relative IRI requires the fallback and a warning that labels cannot apply.""" graph = _make_graph_with_bnodes() - # A relative IRI is non-standard RDF, so pyoxigraph rejects the graph and - # canonicalize_rdf_graph degrades to rdflib -- the same path that - # ``shaclgen --include-annotations`` takes via its literal predicates. graph.add((URIRef("testing"), URIRef("http://example.com/p"), Literal("v"))) - with pytest.warns(RDFCanonicalizationWarning, match="NOT diff-stable"): stable = canonicalize_rdf_graph(graph, diff_stable=True) - with pytest.warns(RDFCanonicalizationWarning): plain = canonicalize_rdf_graph(graph, diff_stable=False) - - # The warning is the contract: the bytes really are identical, which is - # exactly why staying silent would be misleading. assert stable == plain diff --git a/uv.lock b/uv.lock index 3c867304c8..f6e7c86482 100644 --- a/uv.lock +++ b/uv.lock @@ -662,7 +662,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -735,7 +735,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1186,7 +1186,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1728,17 +1728,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } wheels = [ @@ -1756,17 +1756,17 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/34/29b18c62e39ee2f7a6a3bba7efd952729d8aadd45ca17efc34453b717665/ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731", size = 4396932, upload-time = "2025-09-29T10:55:53.948Z" } wheels = [ @@ -1787,7 +1787,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2417,6 +2417,11 @@ dependencies = [ { name = "watchdog" }, ] +[package.optional-dependencies] +diff-stable = [ + { name = "linkml-runtime", extra = ["diff-stable"] }, +] + [package.dev-dependencies] bigquery = [ { name = "sqlalchemy-bigquery" }, @@ -2431,6 +2436,7 @@ dev = [ { name = "ipython-genutils" }, { name = "jsonpatch" }, { name = "jupyter" }, + { name = "linkml-runtime", extra = ["diff-stable"] }, { name = "mock" }, { name = "myst-nb" }, { name = "nbconvert" }, @@ -2483,6 +2489,7 @@ shacl = [ tests = [ { name = "black" }, { name = "duckdb" }, + { name = "linkml-runtime", extra = ["diff-stable"] }, { name = "numpydantic" }, { name = "pyshacl" }, { name = "sqlalchemy-bigquery" }, @@ -2522,6 +2529,7 @@ requires-dist = [ { name = "jsonasobj2", specifier = ">=1.0.3,<2.0.0" }, { name = "jsonschema", extras = ["format"], specifier = ">=4.26.0" }, { name = "linkml-runtime", editable = "packages/linkml_runtime" }, + { name = "linkml-runtime", extras = ["diff-stable"], marker = "extra == 'diff-stable'", editable = "packages/linkml_runtime" }, { name = "openapi-spec-validator", specifier = ">=0.8.4" }, { name = "openpyxl" }, { name = "parse" }, @@ -2542,6 +2550,7 @@ requires-dist = [ { name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.6.0" }, { name = "watchdog", specifier = ">=0.9.0" }, ] +provides-extras = ["diff-stable"] [package.metadata.requires-dev] bigquery = [{ name = "sqlalchemy-bigquery", specifier = ">=1.9.0" }] @@ -2555,6 +2564,7 @@ dev = [ { name = "ipython-genutils" }, { name = "jsonpatch", specifier = ">=1.33" }, { name = "jupyter" }, + { name = "linkml-runtime", extras = ["diff-stable"], editable = "packages/linkml_runtime" }, { name = "mock", specifier = ">=5.1.0" }, { name = "myst-nb", marker = "python_full_version >= '3.10'", specifier = ">=1.4.0" }, { name = "nbconvert" }, @@ -2602,6 +2612,7 @@ shacl = [{ name = "pyshacl", specifier = ">=0.40.1" }] tests = [ { name = "black", specifier = ">=24.0.0" }, { name = "duckdb", specifier = ">=1.5.5" }, + { name = "linkml-runtime", extras = ["diff-stable"], editable = "packages/linkml_runtime" }, { name = "numpydantic", specifier = ">=1.10.0" }, { name = "pyshacl", specifier = ">=0.40.1" }, { name = "sqlalchemy-bigquery", specifier = ">=1.17.2" }, @@ -2631,7 +2642,6 @@ dependencies = [ { name = "click" }, { name = "curies" }, { name = "deprecated" }, - { name = "diffable-rdf" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "json-flattener" }, @@ -2651,6 +2661,9 @@ dev = [ { name = "coverage" }, { name = "requests-cache" }, ] +diff-stable = [ + { name = "diffable-rdf" }, +] [package.dev-dependencies] dev = [ @@ -2664,7 +2677,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'dev'" }, { name = "curies", specifier = ">=0.14.6" }, { name = "deprecated" }, - { name = "diffable-rdf", specifier = ">=0.4.0" }, + { name = "diffable-rdf", marker = "extra == 'diff-stable'", specifier = ">=0.4.0" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'", specifier = ">=0.7.2,<1.0.0" }, { name = "json-flattener", specifier = ">=0.1.9" }, @@ -2679,7 +2692,7 @@ requires-dist = [ { name = "requests" }, { name = "requests-cache", marker = "extra == 'dev'", specifier = ">=1.3.3" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "diff-stable"] [package.metadata.requires-dev] dev = [ @@ -4990,23 +5003,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -5024,23 +5037,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals-py" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ From 4e6a03dbb9a71dd3c40cd5bd1ffb3df01efd8c7e Mon Sep 17 00:00:00 2001 From: noelmcloughlin Date: Thu, 17 Sep 2026 11:33:05 +0100 Subject: [PATCH 06/24] fix(schemaview): get_uri resolves elements of relatively-imported schemas --- .../src/linkml_runtime/utils/schemaview.py | 12 ++++- tests/linkml/test_generators/test_shaclgen.py | 49 +++++++++++++++++++ .../test_utils/test_schemaview.py | 49 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py index 5cb7c6794d..843eb4cf82 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py @@ -1439,7 +1439,17 @@ def get_uri( msg = f"Cannot find {e.from_schema} in schema_map" raise ValueError(msg) else: - schema = self.schema_map[self.in_schema(e.name)] + # schema_map is keyed by the import as written in the importing schema, which + # is not always the imported schema's name: `imports: [./child]` is keyed + # './child' while in_schema() reports 'child'. Fall back to matching on the + # name, as the from_schema branch above does on the id. + schema_name = self.in_schema(e.name) + schema = self.schema_map.get(schema_name) + if schema is None: + schema = next((sc for sc in self.schema_map.values() if sc.name == schema_name), None) + if schema is None: + msg = f"Cannot find schema {schema_name} in schema_map" + raise ValueError(msg) if use_element_type: e_type = e.class_name.split("_", 1)[0] # for example "class_definition" e_type_path = f"{e_type}/" diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index 8604f712de..f367de97e7 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -2784,3 +2784,52 @@ def test_exclusive_value_coexists_with_boolean_guard(): has_boolean = any("BOUND" in q for q in queries) assert has_exclusive, "Expected one exclusive-value SPARQL constraint" assert has_boolean, "Expected one boolean-guard SPARQL constraint" + + +def test_shacl_modular_schema_with_reused_attribute_name(tmp_path) -> None: + """A modular schema imported by relative path generates SHACL (#3878). + + Two classes reuse an attribute name with distinct slot_uris, and the schema declaring + them is imported as ``../nucleo/core``, so its closure key differs from its name. + """ + nucleo = tmp_path / "nucleo" + dominios = tmp_path / "dominios" + nucleo.mkdir() + dominios.mkdir() + (nucleo / "core.yaml").write_text( + "id: https://example.org/core\n" + "name: core\n" + "prefixes: {linkml: 'https://w3id.org/linkml/', core: 'https://example.org/core/'}\n" + "default_prefix: core\n" + "default_range: string\n" + "imports: [linkml:types]\n" + "classes:\n" + " Transaccion:\n" + " attributes:\n" + " id_transaccion: {identifier: true}\n" + " estado: {slot_uri: core:transaccion_estado}\n" + " Compromiso:\n" + " attributes:\n" + " id_compromiso: {identifier: true}\n" + " estado: {slot_uri: core:compromiso_estado}\n" + ) + domain = dominios / "domain.yaml" + domain.write_text( + "id: https://example.org/domain\n" + "name: domain\n" + "prefixes: {linkml: 'https://w3id.org/linkml/', core: 'https://example.org/core/', " + "dom: 'https://example.org/domain/'}\n" + "default_prefix: dom\n" + "default_range: string\n" + "imports: [linkml:types, ../nucleo/core]\n" + "classes:\n" + " Pedido:\n" + " is_a: Transaccion\n" + " attributes:\n" + " importe: {range: float}\n" + ) + + graph = rdflib.Graph() + graph.parse(data=ShaclGenerator(str(domain)).serialize(), format="turtle") + shapes = set(graph.subjects(RDF.type, SH.NodeShape)) + assert URIRef("https://example.org/domain/Pedido") in shapes diff --git a/tests/linkml_runtime/test_utils/test_schemaview.py b/tests/linkml_runtime/test_utils/test_schemaview.py index fa467d82c3..26879ae278 100644 --- a/tests/linkml_runtime/test_utils/test_schemaview.py +++ b/tests/linkml_runtime/test_utils/test_schemaview.py @@ -1678,6 +1678,55 @@ def test_all_enums(schema_view_with_imports: SchemaView) -> None: assert e.from_schema == "https://w3id.org/linkml/tests/core" +def _write_shared_attribute_tree(tmp_path: Path) -> Path: + """Two modules, each declaring the same attribute name, imported as ./child_a and ./child_b.""" + for suffix in ("a", "b"): + (tmp_path / f"child_{suffix}.yaml").write_text( + f"id: https://example.org/child_{suffix}\n" + f"name: child_{suffix}\n" + "prefixes: {linkml: 'https://w3id.org/linkml/', ex: 'https://example.org/'}\n" + "default_prefix: ex\n" + "default_range: string\n" + "imports: [linkml:types]\n" + "classes:\n" + f" Child{suffix}:\n" + " is_a: Parent\n" + " attributes:\n" + " shared_attribute:\n" + " range: string\n" + ) + main = tmp_path / "main.yaml" + main.write_text( + "id: https://example.org/main\n" + "name: main\n" + "prefixes: {linkml: 'https://w3id.org/linkml/', ex: 'https://example.org/'}\n" + "default_prefix: ex\n" + "default_range: string\n" + "imports: [linkml:types, ./child_a, ./child_b]\n" + "classes:\n" + " Parent:\n" + " description: parent class, defined in the importing schema\n" + ) + return main + + +def test_get_uri_element_defined_in_relatively_imported_schema(tmp_path: Path) -> None: + """get_uri resolves an element whose schema was imported under a relative path. + + ``schema_map`` is keyed by the import as written (``./child_b``) while ``in_schema()`` + reports the schema's name (``child_b``). An attribute declared in more than one class + has no ``from_schema``, so the lookup falls back to the key and must tolerate the + difference. This is what makes ``gen-shacl`` fail on modular schemas (#3878). + """ + main = _write_shared_attribute_tree(tmp_path) + view = SchemaView(str(main)) + + # precondition: the attribute is declared in more than one class, so it has no + # from_schema and get_uri has to locate its schema rather than being handed it + assert view.get_element("shared_attribute").from_schema is None + assert view.get_uri("shared_attribute", expand=True) == "https://example.org/shared_attribute" + + def test_get_uri(schema_view_with_imports: SchemaView) -> None: """Test the get_uri function.""" view = schema_view_with_imports From b39f78f94a0223cc7f5b51fae4223aaf6130790d Mon Sep 17 00:00:00 2001 From: N <13322818+noelmcloughlin@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:03:05 +0100 Subject: [PATCH 07/24] chore: update packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py Co-authored-by: Silvano Cirujano Cuesta --- packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py index 843eb4cf82..f20d17e847 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py @@ -1439,12 +1439,11 @@ def get_uri( msg = f"Cannot find {e.from_schema} in schema_map" raise ValueError(msg) else: + schema = self.schema_map[self.in_schema(e.name)] # schema_map is keyed by the import as written in the importing schema, which # is not always the imported schema's name: `imports: [./child]` is keyed # './child' while in_schema() reports 'child'. Fall back to matching on the # name, as the from_schema branch above does on the id. - schema_name = self.in_schema(e.name) - schema = self.schema_map.get(schema_name) if schema is None: schema = next((sc for sc in self.schema_map.values() if sc.name == schema_name), None) if schema is None: From b842129a34fa06b3bc63e4423915030a4ead430b Mon Sep 17 00:00:00 2001 From: noelmcloughlin Date: Sat, 19 Sep 2026 00:02:37 +0100 Subject: [PATCH 08/24] fix(schemaview): restore .get() lookup in get_uri, reverting b39f78f9 --- .../src/linkml_runtime/utils/schemaview.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py index f20d17e847..908f512771 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py @@ -1439,11 +1439,10 @@ def get_uri( msg = f"Cannot find {e.from_schema} in schema_map" raise ValueError(msg) else: - schema = self.schema_map[self.in_schema(e.name)] - # schema_map is keyed by the import as written in the importing schema, which - # is not always the imported schema's name: `imports: [./child]` is keyed - # './child' while in_schema() reports 'child'. Fall back to matching on the - # name, as the from_schema branch above does on the id. + # Two classes reusing an attribute name give a bare placeholder slot with no + # from_schema, so the schema has to be looked up by name instead. + schema_name = self.in_schema(e.name) # the schema's own name, e.g. 'core' + schema = self.schema_map.get(schema_name) # None if imported by path, as keys are import strings if schema is None: schema = next((sc for sc in self.schema_map.values() if sc.name == schema_name), None) if schema is None: From c8ad0218ee7959ae3fa2c5ae10319c45a9f41c01 Mon Sep 17 00:00:00 2001 From: noelmcloughlin Date: Sat, 19 Sep 2026 00:09:26 +0100 Subject: [PATCH 09/24] docs(schemaview): say why get_uri uses .get() rather than a subscript --- .../linkml_runtime/src/linkml_runtime/utils/schemaview.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py index 908f512771..f35bfc64ac 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py @@ -1442,7 +1442,9 @@ def get_uri( # Two classes reusing an attribute name give a bare placeholder slot with no # from_schema, so the schema has to be looked up by name instead. schema_name = self.in_schema(e.name) # the schema's own name, e.g. 'core' - schema = self.schema_map.get(schema_name) # None if imported by path, as keys are import strings + # .get(), not [...]: keys are imports as written, so a relative import misses + # here -- a miss is expected and must fall through to the name match below. + schema = self.schema_map.get(schema_name) if schema is None: schema = next((sc for sc in self.schema_map.values() if sc.name == schema_name), None) if schema is None: From 4f43bd28535fffcdecebb6184ce8bd9def622eac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:59:14 -0500 Subject: [PATCH 10/24] build(deps): bump sqlalchemy --- packages/linkml/pyproject.toml | 2 +- uv.lock | 100 +++++++++++++++++---------------- 2 files changed, 54 insertions(+), 48 deletions(-) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index df18ca8f70..e06b5f3570 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "pyyaml", "rdflib>=7.6.0", "requests >= 2.22", - "sqlalchemy>=2.0.53", + "sqlalchemy>=2.0.54", "watchdog >= 0.9.0", "typing-extensions >= 4.6.0; python_version < '3.12'", "sphinx-click (>=6.0.0)", diff --git a/uv.lock b/uv.lock index 35dada7d4f..d11999ff8f 100644 --- a/uv.lock +++ b/uv.lock @@ -2524,7 +2524,7 @@ requires-dist = [ { name = "rdflib", specifier = ">=7.6.0" }, { name = "requests", specifier = ">=2.22" }, { name = "sphinx-click", specifier = ">=6.0.0" }, - { name = "sqlalchemy", specifier = ">=2.0.53" }, + { name = "sqlalchemy", specifier = ">=2.0.54" }, { name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.6.0" }, { name = "watchdog", specifier = ">=0.9.0" }, ] @@ -3673,11 +3673,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.8" +version = "4.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/b9/8adc4e1b422b27fd88540ec7bf1f406f77ef393ec070e26fc430e914cde8/platformdirs-4.11.9.tar.gz", hash = "sha256:e2c66a8d384596cd98e3c4aea2d761df7bac95d9d8a2cc3946daa8cdafdaebc1", size = 38345, upload-time = "2026-09-16T13:31:45.259Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/803ba86705257d7eedddac4b02eb88a7483b1600e9200c1fefc6f1a9a3ff/platformdirs-4.11.9-py3-none-any.whl", hash = "sha256:0a3958f58a9e30321eaef0a424dd0b77cce242886b36b8aa992f9731ef2d59c1", size = 24472, upload-time = "2026-09-16T13:31:44.049Z" }, ] [[package]] @@ -5191,51 +5191,57 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.53" +version = "2.0.54" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/de/59fc3d2e63dfd367e803417c40e70e35b6c0a0997420cb082d351cdd052b/sqlalchemy-2.0.53.tar.gz", hash = "sha256:b568a8ac0778988c2710476017ff74fb5dcb9b9322d757189c3d7fc4cc8026fa", size = 9966200, upload-time = "2026-09-14T20:37:31.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/4b/303c139417e53e7827e4f46a44a5bcc3f50ce713029abf26d79d93f2f314/sqlalchemy-2.0.53-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cfd51b94590814cfa5020534225f61dda1af485299cd2dbb1ee8112d0e92b52f", size = 2189252, upload-time = "2026-09-14T22:39:47.549Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bf/43ed9a166b20f4d0edd7f7b6af3d40801aba9498c3223f4dcbd4afe0a735/sqlalchemy-2.0.53-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f90d64f69cd80ef0acba1e92357bb412e4ba71476b745c83d1499c3b0383303", size = 3358346, upload-time = "2026-09-14T22:46:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/55/74/c61a6c8ccc5c7db3d2d85e2d28233bc82f9fd775986a858f52da46e45146/sqlalchemy-2.0.53-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7d6d72fcffbba8258cfe663786b87d711812e09dad757a1f58996ab7f0d8f2", size = 3357676, upload-time = "2026-09-14T22:50:10.66Z" }, - { url = "https://files.pythonhosted.org/packages/8a/36/8cdbfa4b5652413028bff4714d2b72b5c7b4505d5e8a37f627e7bf4da8ea/sqlalchemy-2.0.53-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:12a3bd96dafcf830bcebeecbbda5921cdd57e41bd074646c285a92d4f76862c6", size = 3308245, upload-time = "2026-09-14T22:46:42.773Z" }, - { url = "https://files.pythonhosted.org/packages/db/15/ad2c2e030350b58a59b433c7740a49f724b879de623ed589a9bf57505998/sqlalchemy-2.0.53-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:49e366b972504f0c600de8ad58054017d7b7a5176e5765cea7df76b5e05548d2", size = 3336719, upload-time = "2026-09-14T22:50:12.443Z" }, - { url = "https://files.pythonhosted.org/packages/73/16/a9e95ebbec394f88d4a072506f389f91e87e0699059455da57b3f80b6ac9/sqlalchemy-2.0.53-cp310-cp310-win32.whl", hash = "sha256:d31e2714d6f5df4a308d8e5aafecb6aea1bc386bbee671044492f9a124461ce1", size = 2144280, upload-time = "2026-09-14T22:55:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/df/98/184d7d53c27bf5473a3764954bdf8a85a753aba4323474b05a2d820359b1/sqlalchemy-2.0.53-cp310-cp310-win_amd64.whl", hash = "sha256:3fe213af5d7e11c91692505ecadb7b0980c424b050d8b6458e39f38090094d7c", size = 2170302, upload-time = "2026-09-14T22:55:57.57Z" }, - { url = "https://files.pythonhosted.org/packages/e7/58/68f32c4428e01aa784d045d74fdd9ceacca1f27b7030d468a7c90dcb0839/sqlalchemy-2.0.53-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0a3481fdea08ce9f93c318e40c446c91bc63260a5eae1cf06d85b4d4d5651d4", size = 2187761, upload-time = "2026-09-14T22:39:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ad/57b82748084536340703d42b23694a259bef66db17f21cf37a0475480cfa/sqlalchemy-2.0.53-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29e7eb8bb305411f26ead6854e7591cfc6a5b3b6b924a31bd8b843ba243a3ebd", size = 3428938, upload-time = "2026-09-14T22:46:44.295Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f9/9c719de2e6628b8d0884082590da324674a10ec3bb177face202a8bb439b/sqlalchemy-2.0.53-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:255c1ca3810da6994b755c80b3abc27a5fb1d45b6703985092a2f6663f9daa9e", size = 3428551, upload-time = "2026-09-14T22:50:14.85Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0c/93586869f7c852b4138736d9ea6121f4b2f967878bec616a36caa9cb2502/sqlalchemy-2.0.53-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:764eebce5bf2046172f6f32d51ee47b0acc0dbdb05262a6382875267bc5b97d4", size = 3377684, upload-time = "2026-09-14T22:46:45.882Z" }, - { url = "https://files.pythonhosted.org/packages/bb/4d/078b5badf416692545382a8cc803230f27dab29ab2dc970840566a221b7a/sqlalchemy-2.0.53-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ab1489a2a417f5d8f03d2824771e168ac2da8db9c34e05de8a404182390d67e7", size = 3402953, upload-time = "2026-09-14T22:50:16.54Z" }, - { url = "https://files.pythonhosted.org/packages/61/24/31d22eee205f611286cfc172dcc71f502454895a469e66ef5ecb32998241/sqlalchemy-2.0.53-cp311-cp311-win32.whl", hash = "sha256:90242b1ad2bdad5fcebf32fb2971465edd4034e0381c1d6126bcb5a5b1851f18", size = 2142283, upload-time = "2026-09-14T22:55:58.909Z" }, - { url = "https://files.pythonhosted.org/packages/80/bd/6ac820a60ca104a20977b267381ebe78d84fdf3aaaa44fd98a952cdcc962/sqlalchemy-2.0.53-cp311-cp311-win_amd64.whl", hash = "sha256:05faf168e324b5c609df10530d25620fcce7a815635c653903d3f459617b85bd", size = 2169208, upload-time = "2026-09-14T22:56:00.347Z" }, - { url = "https://files.pythonhosted.org/packages/57/a9/fa8d769bdbcdf9db40ce1491da397bb7ea45e381791800319b4e7c5d6635/sqlalchemy-2.0.53-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ca22c441645ead511eb73f1efdb0f3a723f138e28f8362224813f64a8097e15", size = 2187584, upload-time = "2026-09-14T22:43:16.399Z" }, - { url = "https://files.pythonhosted.org/packages/52/a7/4534510e1deda24a505504194d805f22ef592bb51243a4f4b2f97f116593/sqlalchemy-2.0.53-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d26a8411cedab7102f31d766711f3e5fd93bf7d3a1470200bacaaaac7faaa826", size = 3448731, upload-time = "2026-09-14T22:49:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/30c213493d215e685d6ba8a0d8ead0fb908e731f07f924ae2ab77297aec8/sqlalchemy-2.0.53-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9049aaeae45d30dfa3bfba105175a358bf32e9fa563e281a016cf44d8aaa6b5f", size = 3466629, upload-time = "2026-09-14T23:06:58.975Z" }, - { url = "https://files.pythonhosted.org/packages/07/90/f1b1ce1ec34f6c8b81c557ffda2a7fe191c0bed64e649a720e5feee7c5ab/sqlalchemy-2.0.53-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0417dac5a5bbc7bff3fd95f53ff193c0fdcbdde4b3119ca2f46651190e65144c", size = 3401504, upload-time = "2026-09-14T22:49:37.94Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6c/e3721f102b8549831036465f368e7bf3e96dcfa9920ee36d9a69fba89e88/sqlalchemy-2.0.53-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6499513a0ca3984f7e4c4af22dbccc156eeda8846a4c4a2287a5b5e33b3544ff", size = 3432576, upload-time = "2026-09-14T23:07:00.982Z" }, - { url = "https://files.pythonhosted.org/packages/8c/13/9e5ab604abf0f8a229daa68b413ef717a76042db6b43ea0b3fbeddc3b045/sqlalchemy-2.0.53-cp312-cp312-win32.whl", hash = "sha256:dd915d37ae2442be08d6e1317814ac9be173341a1ad83ce133a77b4abba2d09d", size = 2140815, upload-time = "2026-09-14T23:03:36.688Z" }, - { url = "https://files.pythonhosted.org/packages/a2/de/0cd77e71f217cb6d849bd830b093710cf3cb2dc3a2d0af2361cf8d12b54a/sqlalchemy-2.0.53-cp312-cp312-win_amd64.whl", hash = "sha256:bb684d78694f77991cfc6fd8ba3bba093f06d7efbc354ea84b077a0d2116464d", size = 2169991, upload-time = "2026-09-14T23:03:38.168Z" }, - { url = "https://files.pythonhosted.org/packages/89/0e/394bbfadbbad7e427a86bc2f553eeea0bdd1f3f268aafed8cdf78af8cb41/sqlalchemy-2.0.53-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95b04f3113d8e02b711bbb3f6be5493ed94585b086b5b68571ea324e175022e5", size = 2184241, upload-time = "2026-09-14T22:43:17.736Z" }, - { url = "https://files.pythonhosted.org/packages/78/70/0ab026a838a0213b0b62521a49abbcd66d56d8e54ae8726d2aa268cbabb6/sqlalchemy-2.0.53-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acc9ee56130c32b609f562ac4041f77311aa6b3c51d507bd3ac93e280631eeed", size = 3398629, upload-time = "2026-09-14T22:49:39.568Z" }, - { url = "https://files.pythonhosted.org/packages/c8/4c/294eb6a87d5133874a04d2e7257fcf843176d6fbd1ba4ef5b3152fff861e/sqlalchemy-2.0.53-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f3a9a4e82bf984d840c26dafd19688a4e2bfc7401d423cbec960ec737c610f", size = 3417528, upload-time = "2026-09-14T23:07:02.923Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a3/ce985c9aee8e123174a022dd72aaa435b9c077a7d9f973d56ad2ad566638/sqlalchemy-2.0.53-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ff97dfff33e9c81e57be565ac58b9299affb566e10570cca668e4d67c69567a", size = 3349198, upload-time = "2026-09-14T22:49:41.2Z" }, - { url = "https://files.pythonhosted.org/packages/bf/bc/9dbae8af3dc6f14b14d08c3aeecbe1f3c5884ed1240f7509e8a2270297e2/sqlalchemy-2.0.53-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0cdefc36945decf40c199ee39f9df3f0765754b9625cf136118b04206854a05a", size = 3382852, upload-time = "2026-09-14T23:07:04.554Z" }, - { url = "https://files.pythonhosted.org/packages/29/04/228ab9a0115b8219c4af3071fead000b6c5c11696d2eae24163b6ff249d5/sqlalchemy-2.0.53-cp313-cp313-win32.whl", hash = "sha256:b06667e375da212f2ff4e7e54bf547ffeb63700c0743fc21c107f7d64f632922", size = 2138920, upload-time = "2026-09-14T23:03:39.638Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7f/8b93de13745cc35540cbbd45e5d2247787fe9fec0ac7c9e64c7842f8f4dc/sqlalchemy-2.0.53-cp313-cp313-win_amd64.whl", hash = "sha256:88c296bd7b6e653d428aa0c97e806ac1e0ad25c385f4dab627b832ba46b7ad91", size = 2166486, upload-time = "2026-09-14T23:03:41.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/164f0b33493a694a525e174d1ff978264167315d85f5128657d67522932a/sqlalchemy-2.0.53-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bc02d73633f5cee4ebecfc6c20b09a28bdfd940cc2ccf5bf119a6a7de6b5a3a3", size = 2185890, upload-time = "2026-09-14T22:43:19.088Z" }, - { url = "https://files.pythonhosted.org/packages/bf/85/15503b7c81567602a88a45ae14ecaef2f9372bf82e4cd2e3c7588e8cdea5/sqlalchemy-2.0.53-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b06e32d92ed898240793982820c46ae22bdec4f24dcb4ae4a6859001674db186", size = 3394772, upload-time = "2026-09-14T22:49:42.787Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bb/a35e0ccb4f1d6f37b7b9d8810de787cbd429c427082e77e0ecdedf99b313/sqlalchemy-2.0.53-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03dfba40d1de2da62370d5e85ba13ffb8e75f16e8af9e93170a9cf4f394636a5", size = 3402720, upload-time = "2026-09-14T23:07:06.829Z" }, - { url = "https://files.pythonhosted.org/packages/40/06/adb43aa08a16e8abf55148a2efd17a66291a217c461455d7b4de053fd092/sqlalchemy-2.0.53-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:659daf25d61973ad6dff7bbfe9fccd116da2ab27618f57c3b9bfb97cb02000f5", size = 3341444, upload-time = "2026-09-14T22:49:44.316Z" }, - { url = "https://files.pythonhosted.org/packages/ee/07/15eaf0ae3573a9fb3ad0e5437f8cb36e03e415839ba6e77c645f555fb0b2/sqlalchemy-2.0.53-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:577195de5eccba4fea6c289e7537e4d309d9767d16b31afe82deda1f241feb80", size = 3373151, upload-time = "2026-09-14T23:07:09.063Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c7/b5c078b0d36adb22cee1aca21e745e20e69705357c1f0dae2d8d728360fa/sqlalchemy-2.0.53-cp314-cp314-win32.whl", hash = "sha256:eeb39947f8d3a0531afdcb422f659e6ac09653b50813dbedc9baf84ce2358320", size = 2142012, upload-time = "2026-09-14T23:03:42.677Z" }, - { url = "https://files.pythonhosted.org/packages/59/4d/81b6bf1eaa969fc74b54104f6059d08f52383416654e8149b55ad168f2fb/sqlalchemy-2.0.53-cp314-cp314-win_amd64.whl", hash = "sha256:414a71d25b18cd3d1d5ab18b512a550a34398dcfbd68336adff5cd1e93fc7f0d", size = 2169212, upload-time = "2026-09-14T23:03:44.125Z" }, - { url = "https://files.pythonhosted.org/packages/eb/27/06028ccab7e03887c68c5031a8e4e7bfc85f30c87449c79dcba49e7ccd55/sqlalchemy-2.0.53-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41548248e8a5897d3a5f67ad8c1edb78efe72ec00dcc985b5c19b7850949d039", size = 2208469, upload-time = "2026-09-14T21:24:39.051Z" }, - { url = "https://files.pythonhosted.org/packages/fc/3c/6ee5fe746b0820335d30c5172527e9a2c7409ac632376d15fded38ff715c/sqlalchemy-2.0.53-py3-none-any.whl", hash = "sha256:87eb05fc61a0835dfe1cbf135d0f99cb2764ad09350d92fdd3fe1b827a557d65", size = 1958002, upload-time = "2026-09-14T22:40:04.431Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/271aa905cf2964f841371a97f3e63ab692bf51b4423d0491e67bc7f64037/sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b", size = 9969559, upload-time = "2026-09-15T21:06:57.337Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/2f/7a427d7e10bc5f285d41fa1cadf46031ffc6c84f35a9d6e19737587a4eb0/sqlalchemy-2.0.54-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24ae093dec196ba37fc2beb0316de53e7871d3d246a50faecbbb53034e41ded2", size = 2189267, upload-time = "2026-09-15T22:11:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/80122de187c0f80cb47addce97609160004a8605010afdd815e608fcf003/sqlalchemy-2.0.54-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cc6532f930c27974e9239e5ce5abebe7600ba9807cea4fcf42f1b6cab18fe7", size = 3358362, upload-time = "2026-09-15T22:33:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/c8/93/9d26b109f5472017bf6e7a1b9dac12ac5f6165ba37a297834d0690c42cf3/sqlalchemy-2.0.54-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e7a76d5dce712ce50435d0f97181eb955ec27d138c004176f01282e063bac52", size = 3357692, upload-time = "2026-09-15T22:33:14.781Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d4/63e800b45bd4fa59cb4b14c013130e0aa92c7a019a0b8bf68115643bf32b/sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f5c09090b1a7c4d389d1431f820931e8df318f82caafc53f9a72c872fef467c5", size = 3308258, upload-time = "2026-09-15T22:33:38.469Z" }, + { url = "https://files.pythonhosted.org/packages/f4/fe/6c96fe018b9db15476b46619228dc0749cdd9b4dc8baab89aca59205b605/sqlalchemy-2.0.54-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:762cfe4d340c56368256d936a98b620a9a5650e49c1c84eba51d6edd17ffefb2", size = 3336734, upload-time = "2026-09-15T22:33:16.533Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f1/8d225d6b77863ae83ba51e913fe5954bcf58b49dd54229f0b5ba0bc983f0/sqlalchemy-2.0.54-cp310-cp310-win32.whl", hash = "sha256:6b6d4e601c4f6d85e99bb3416107cc9418c5603ca73d4ee0f5f8d79c2a1ed9e8", size = 2144294, upload-time = "2026-09-15T22:41:20.424Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f6/4247fdd087bf1b39be90930bb2b276a1c55041540628c8736512b4242669/sqlalchemy-2.0.54-cp310-cp310-win_amd64.whl", hash = "sha256:03cbf8d9a67da618bd65500a5eb3ddac89caf4c61e99b2f03fa4a1952a0725a9", size = 2170318, upload-time = "2026-09-15T22:41:21.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/7339b18ef05c03335a32f077e450c8040eab0c952c2964e32cd889eafc22/sqlalchemy-2.0.54-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d03084f3352dd92048cb19c71d90f116d076c9c7937e0ebc7752c4685de6d38", size = 2187776, upload-time = "2026-09-15T22:32:35.535Z" }, + { url = "https://files.pythonhosted.org/packages/9b/bb/3b6f5f3f51582d63af4c88b1e70da7888e936dee6b86a237b7633fb3f216/sqlalchemy-2.0.54-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92622fbbda1b1fe1632f3402a6e516a93c0e41d9158839c6b3dfb12117f26b72", size = 3428952, upload-time = "2026-09-15T22:40:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/ffd6b59795ebec81d582ad866f71ec7c73d356768e0442c6a07bba61c40c/sqlalchemy-2.0.54-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5800ddea045c2c860ef1d359a07a3066c7c0c426f45e3abc3874e116cb3c6937", size = 3428566, upload-time = "2026-09-15T22:35:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/63/ff/1f0bffb0653b30711e2445a48968eff9412b4e5bf0870f1e72e8a69595ef/sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1019abef05a4b5eafc8eae6fb483167fa28a4dbe5f518d577b744f31a5276a37", size = 3377697, upload-time = "2026-09-15T22:40:16.494Z" }, + { url = "https://files.pythonhosted.org/packages/97/76/774906be0d41cc4a14ecfbc015112fb7d0a3f1c3393f2c179d10388f8f0b/sqlalchemy-2.0.54-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b67749f7da3985a529cefbb1474783cb91ef44371cb9713630bade3de908760d", size = 3402966, upload-time = "2026-09-15T22:35:35.726Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bb/1d7fe50ace612bf342d3440d8b09544746949120326c3dc487cfc2592fba/sqlalchemy-2.0.54-cp311-cp311-win32.whl", hash = "sha256:2f61a70b3b82e2ec7ad6a4f2301422b9ca93ff06917983e41317bcae878bddf6", size = 2142297, upload-time = "2026-09-15T21:25:19.917Z" }, + { url = "https://files.pythonhosted.org/packages/d6/49/18fe80f64b1f6bcd422e649e40a78031f8624b2fde22ca54e950dfa6e25a/sqlalchemy-2.0.54-cp311-cp311-win_amd64.whl", hash = "sha256:1d887fbd5d248e250807bd801e697fc73e3b44866ce5f093dbc90512e75bde25", size = 2169222, upload-time = "2026-09-15T21:25:21.114Z" }, + { url = "https://files.pythonhosted.org/packages/c6/30/75504fd1d70458000e85a3e772333dd0fbf80254b0dc4d41c99922e4d112/sqlalchemy-2.0.54-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffba7eb2d67c7505e82a0902aa854d8824b74c28a183820d6a8bd3cfd0f812c2", size = 2187596, upload-time = "2026-09-15T22:32:37.031Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3d/5dfbb9528a391186a99986daecc5cbe003f34408dee91db8f5cdcf917040/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63cae7210fea9899e0bf35c1f1ae55d3ddd9c6d47cae8b6b43d945afa79dd65b", size = 3448745, upload-time = "2026-09-15T22:40:18.168Z" }, + { url = "https://files.pythonhosted.org/packages/d9/93/34fdc4a4faced77037a6b3ba1db1acd92e9bd12c21e229b327edd1d3e881/sqlalchemy-2.0.54-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68d994e9b0d0423a02a20039631fa6fcbb7fa829a992f7605025774940305d19", size = 3466644, upload-time = "2026-09-15T22:35:37.7Z" }, + { url = "https://files.pythonhosted.org/packages/66/68/4beab40ae60ac3d679dbbb46bc0d2bb277013264a0bde37ecaf4b6780e98/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3de32cc6721eb42c3aad35bcfb244bb7a18f66c00f3582aae6281d6287a339b5", size = 3401519, upload-time = "2026-09-15T22:40:19.907Z" }, + { url = "https://files.pythonhosted.org/packages/e5/df/a24757e3249b1c7c1c5f0317666d16a8ab901ba239819ecef29eb4ed172e/sqlalchemy-2.0.54-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d31a2bc06a854ee52dd86b455be4df7c750b28817e2d1b884e31fff126c4fd7b", size = 3432590, upload-time = "2026-09-15T22:35:39.434Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0c/69559e3d90d200fbfe9c44aff39a9ff4e506b1a3e9557a203d69df721244/sqlalchemy-2.0.54-cp312-cp312-win32.whl", hash = "sha256:32de6deded25e8b9b11d07428d496ff24dfbc882b8e990c177266948cb5f3d9e", size = 2140825, upload-time = "2026-09-15T21:25:22.444Z" }, + { url = "https://files.pythonhosted.org/packages/d9/10/4a0f7113664c2877906db143709c52ddbd28a88e4d51f0b520bc30048ba7/sqlalchemy-2.0.54-cp312-cp312-win_amd64.whl", hash = "sha256:d65f8ca742ef1e1e14bc417ef59dc2ddf207a7b66b30cfdc6152447314e030cf", size = 2170005, upload-time = "2026-09-15T21:25:23.885Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/021ccd7a838425f52e67c02edc541e8d53be8a27a103e684e14b18aac17b/sqlalchemy-2.0.54-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b374e3bc91e246a942592a98ba6a23be76fff21358b00546ac8c0ebc0fd0e00b", size = 2184259, upload-time = "2026-09-15T22:28:59.153Z" }, + { url = "https://files.pythonhosted.org/packages/74/8f/f95de908a4af7ac3a6925cdcb837a93274f608925a0fbc786df38811bccf/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d5458672a6f72db2c087f4a5098b3c8503ea0254186ff29205d63afa9401a4", size = 3398792, upload-time = "2026-09-15T22:29:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/84/26/bd327a1a6be223e438c98ac8faf299307d83797225a81f3b209607a9fd98/sqlalchemy-2.0.54-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cad78d04254967bdbcccbed5e631d88fe4868530946ab0929aa45e9032849518", size = 3417562, upload-time = "2026-09-15T22:40:32.637Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b1/f13a8fe8e8e167d8e65484b91b827897b8a5ca1efd7ba6e87a5f63412fed/sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:48611087a75d26d798003645c688c7d3cfc26b89dbe4a2c568d6b378d330deae", size = 3349138, upload-time = "2026-09-15T22:29:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/5fd58f03281a74c57f02f509433e1be58a79d9f5b0e14020e665e0dcb614/sqlalchemy-2.0.54-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d6adf80277372a89910a0f3ccfe960b846d279dc55b366dd5c5ec07f41c84758", size = 3382943, upload-time = "2026-09-15T22:40:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/4b/73/e29fa88dfc809857a55e6523911d345d00cab4adf87032c21ade8874223c/sqlalchemy-2.0.54-cp313-cp313-win32.whl", hash = "sha256:264460333ed0b177cbb1956355d0ee4e0cab83fb415c934ce12a25db2e7be39c", size = 2138931, upload-time = "2026-09-15T22:42:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/89/9d/885e7491836f3dad368c74f0d5b93551d37b0ec66c9e8753240ee988308f/sqlalchemy-2.0.54-cp313-cp313-win_amd64.whl", hash = "sha256:cf89e92bf0d4204a6afcc17af27b9271ed9c7e34e17d6f80c085d431ea4a1747", size = 2166496, upload-time = "2026-09-15T22:42:53.603Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c0/4a6503c9d22d6d00a5631082ab1484222ecf7d573db791e0f53161bf7745/sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0", size = 2185899, upload-time = "2026-09-15T22:29:00.581Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/f4424f618bd1f373761a32a821d53ce2c257350e9894bae9b968cb03d8fd/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139", size = 3394763, upload-time = "2026-09-15T22:29:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/59/d2/7f0c77f8e042cb5f28275fea29c3080b4ac6fd4b3fdd7f59ff1ef3e28c11/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e", size = 3402800, upload-time = "2026-09-15T22:40:36.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/32/3eaa930bcf71d17a72587081d2706a5fb97ab3f11a7e0fb838f581f7cff1/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912", size = 3341469, upload-time = "2026-09-15T22:29:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/eb/cc/cddb6cbd4408e5c55b3bf722be26b9d3b54d42509f901b7ecc15debd3d1f/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615", size = 3373232, upload-time = "2026-09-15T22:40:39.181Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/9c2aa5efc642b7f3b985d13565cd1a5e78856e079ef3022796fea5180498/sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44", size = 2142018, upload-time = "2026-09-15T22:42:55.118Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0b/3594f1f51769feb3022d686135dc5d8682a12345ed15ae61d0c0ca42cbee/sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835", size = 2169220, upload-time = "2026-09-15T22:42:56.727Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a5/c211a9a7af83222509519407e16a4db760c6df3d03be69ebc5414d465321/sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a", size = 2208458, upload-time = "2026-09-15T22:30:05.718Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2e/490ad7b3731116cb48ba170f7722eaa99a89707193e54389ca84b7ad55af/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77", size = 3660585, upload-time = "2026-09-15T22:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/eb/25/15dfe6814847eeda773bd58ab6cf42a94b0176e5cf25a578fc1165777160/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd", size = 3624442, upload-time = "2026-09-15T22:36:38.377Z" }, + { url = "https://files.pythonhosted.org/packages/aa/19/724d0a6a2fb2a86ff2d6008e581c258f722d9b7d8e52adc7b79085febdd4/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f", size = 3562972, upload-time = "2026-09-15T22:36:08.581Z" }, + { url = "https://files.pythonhosted.org/packages/49/bb/9df1bd81c2f2d000cf5e7a1b1a9b331468a3aab939ad983355e701fa42b2/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb", size = 3576479, upload-time = "2026-09-15T22:36:40.272Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/b5775465d3b89061d7c46057c31c56ff8fb6c509550b2b0c6570ffc248b3/sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51", size = 2174794, upload-time = "2026-09-15T22:31:45.925Z" }, + { url = "https://files.pythonhosted.org/packages/77/f8/296c2e46b4ccd3f29b00b954ef2f195dde32f98e352ed21de1d292cedc0d/sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970", size = 2211942, upload-time = "2026-09-15T22:31:47.368Z" }, + { url = "https://files.pythonhosted.org/packages/24/a1/bd5e3e99bc9c8863b51ac5b9b03008a7f2da8c6b59695992f5c654e1265b/sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2", size = 1958015, upload-time = "2026-09-15T22:24:22.95Z" }, ] [[package]] @@ -5456,7 +5462,7 @@ wheels = [ [[package]] name = "tox" -version = "4.61.4" +version = "4.61.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -5472,9 +5478,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/57/5a24f6f81ee81a25d34773045db61ddef3a76ed43e6fbd0fca2657678ad6/tox-4.61.4.tar.gz", hash = "sha256:d3fa82dc2fe847fe45b576ed0614e850a674aadec3a664f409e2bf295c473e20", size = 308140, upload-time = "2026-09-09T01:37:12.183Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/c8/1a333d5addd65474030ead665d65af8e7a189ebf62983f3b7e4ab2d0627c/tox-4.61.5.tar.gz", hash = "sha256:d1f535a6ca4827c087e0a2a173dd74621d6e1688c740c8dd44e0377480020431", size = 308508, upload-time = "2026-09-17T04:01:49.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/ca/1c3c0e8c21caa393648ec6d62ee14aa802ea0d94553ceb2dd71f50b85a10/tox-4.61.4-py3-none-any.whl", hash = "sha256:1ec27a26f485c408a3f128722959086099894955d1eb74ab973e233bd362e99e", size = 228143, upload-time = "2026-09-09T01:37:10.621Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/50231b6fda8759d4451c377f0c4cfb49f4f339c8a15eee1f186f423f205b/tox-4.61.5-py3-none-any.whl", hash = "sha256:5f9b109fa0a60b7ccb812e8dacbcf7a06b9a633fa90ae97198334b3464b2f133", size = 228128, upload-time = "2026-09-17T04:01:48.182Z" }, ] [[package]] From 9cf563ce87e46f97b34dba3534fb49db0ae4b0cd Mon Sep 17 00:00:00 2001 From: N <13322818+noelmcloughlin@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:55:04 +0100 Subject: [PATCH 11/24] fix(schemaview): keep track of which schema requested each import (#3855) * fix(schemaview): keep track of which schema requested each import * fix(schemaview): keep URL import keys intact in the closure --- .github/scripts/check_links.py | 2 + .../src/linkml_runtime/utils/schemaview.py | 94 ++++++++++++++----- .../test_utils/test_schemaview.py | 86 +++++++++++++++++ 3 files changed, 159 insertions(+), 23 deletions(-) diff --git a/.github/scripts/check_links.py b/.github/scripts/check_links.py index 351549a85c..91d06622e0 100644 --- a/.github/scripts/check_links.py +++ b/.github/scripts/check_links.py @@ -52,6 +52,8 @@ "snomed.info", # LinkML metamodel - w3id redirects to linkml.io/linkml-model which may have issues "w3id.org", + # Certificate expired Sep/24/2026 + "datashapes.org", } diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py index 5cb7c6794d..d0eb48d60d 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py @@ -333,6 +333,16 @@ def set_modified(self) -> None: self._hash = None self.modifications += 1 + def _resolved_importmap(self) -> dict[str, Any]: + """User-supplied importmap merged over the built-in ``linkml:`` metamodel mapping. + + Values are usually strings, but may be an in-memory schema dict (see ``load_import``). + """ + from linkml_runtime import SCHEMA_DIRECTORY + + # user entries come last so they can override the built-in linkml: entry + return {"linkml:": str(SCHEMA_DIRECTORY), **self.importmap} + def load_import(self, imp: str, from_schema: SchemaDefinition | None = None) -> SchemaDefinition: """Handle import directives. @@ -361,10 +371,7 @@ def load_import(self, imp: str, from_schema: SchemaDefinition | None = None) -> """ if from_schema is None: from_schema = self.schema - from linkml_runtime import SCHEMA_DIRECTORY - - default_import_map = {"linkml:": str(SCHEMA_DIRECTORY)} - importmap = {**default_import_map, **self.importmap} + importmap = self._resolved_importmap() # An importmap entry may be an in-memory schema dict, which is loaded # directly without touching the filesystem or network. mapped = importmap.get(str(imp)) @@ -437,6 +444,38 @@ def _get_dict(self, element_name: str, imports: bool = True) -> dict: return d + def _load_closure_import(self, sn: str, raw_imp: str | None, importer_sn: str | None) -> SchemaDefinition: + """Load one schema for :meth:`imports_closure`. + + ``sn`` is the closure key (possibly normalized as root-relative); ``raw_imp`` is the + import exactly as written in the importing schema; ``importer_sn`` is that schema's key. + + When an importmap or CURIE mapping applies to ``sn``, resolution stays relative to the + origin schema. Otherwise the raw import is resolved against the *importing* schema's + recorded ``source_file``, so a schema an importmap redirected outside the root tree + still finds its own relative imports (#3499). For unredirected trees both resolutions + denote the same file. Fall back to origin-schema resolution when the importer has no + ``source_file`` (e.g. in-memory schemas). + + Pre-existing limitation: distinct files imported under the same un-normalizable name + share one closure key; the first one loaded wins. + """ + # raw_imp is None only for the root schema (it has no importer) + if raw_imp is None: + return self.load_import(sn) + importmap = self._resolved_importmap() + # an in-memory schema dict has no location of its own, and a mapping hit on sn means an + # importmap/CURIE entry governs this key; both resolve against the origin schema + if isinstance(importmap.get(str(sn)), dict) or map_import(importmap, self.namespaces, sn) != sn: + return self.load_import(sn) + importer = self.schema_map.get(importer_sn) + # without a source_file there is no directory to resolve the import against + if importer is None or not importer.source_file: + return self.load_import(sn) + # raw_imp, not sn: resolving the root-relative key against the importer's directory + # would apply the relative prefix twice (subdir/types against /subdir/) + return self.load_import(raw_imp, from_schema=importer) + @lru_cache(None) def imports_closure( self, imports: bool = True, traverse: bool | None = None, inject_metadata: bool = True @@ -473,7 +512,9 @@ def imports_closure( closure = deque() visited = set() - todo = [self.schema.name] + # (closure key, import name as written in the importing schema, importing schema's key); + # the root schema has no importer, hence the Nones + todo = [(self.schema.name, None, None)] if traverse is not None: warnings.warn( @@ -482,13 +523,13 @@ def imports_closure( ) if not imports or (not traverse and traverse is not None): - return todo + return [self.schema.name] while len(todo) > 0: # visit item - sn = todo.pop() + sn, raw_imp, importer_sn = todo.pop() if sn not in self.schema_map: - self.schema_map[sn] = self.load_import(sn) + self.schema_map[sn] = self._load_closure_import(sn, raw_imp, importer_sn) # resolve item's imports if it has not been visited already # we will get duplicates, but not cycles this way, and @@ -499,30 +540,37 @@ def imports_closure( if i == sn: continue - # resolve relative imports relative to the importing schema, rather than the - # origin schema. Imports can be a URI or Curie, and imports from the same - # directory don't require a ./, so if the current (sn) import is a relative - # path, and the target import doesn't have : (as in a curie or a URI) - # we prepend the relative path. This WILL make the key in the `schema_map` not - # equal to the literal text specified in the importing schema, but this is - # essential to sensible deduplication: e.g. for + # compute the closure key for this import: when the current (sn) key is a + # relative path and the target import has no : (as in a CURIE or URI), the + # import is normalised against sn's parent. This makes the key in the + # `schema_map` not equal to the literal text specified in the importing + # schema, but it is essential to sensible deduplication: e.g. for # - main.yaml (imports ./types.yaml, ./subdir/subschema.yaml) # - types.yaml # - subdir/subschema.yaml (imports ./types.yaml) # - subdir/types.yaml # we should treat the two `types.yaml` as separate schemas from the POV of the - # origin schema. - - # if i is not a CURIE and sn looks like a path with at least one parent folder, - # normalise i with respect to sn - if "/" in sn and ":" not in i: + # origin schema. The key identifies the schema and drives importmap/CURIE + # lookups; locating the actual file happens in _load_closure_import, which + # uses the raw import name and the importer's own location. + + # if i is not a CURIE and sn looks like a filesystem path with at least one + # parent folder, normalise i with respect to sn. + # + # URLs are excluded: os.path.normpath() collapses the double slash in a + # scheme, so file://a/b would become file:/a/b, and the mangled key is then + # indistinguishable from a CURIE. A URL-keyed schema keeps the literal + # import as its key and is located by _load_closure_import instead, which + # resolves it against the URL the importing schema was fetched from. + if "/" in sn and "://" not in sn and ":" not in i: if WINDOWS: # This cannot be simplified. os.path.normpath() must be called before .as_posix() - todo.append(PurePath(os.path.normpath(PurePath(sn).parent / i)).as_posix()) + key = PurePath(os.path.normpath(PurePath(sn).parent / i)).as_posix() else: - todo.append(os.path.normpath(str(Path(sn).parent / i))) + key = os.path.normpath(str(Path(sn).parent / i)) else: - todo.append(i) + key = i + todo.append((key, i, sn)) # add item to closure # append + pop (above) is FILO queue, which correctly extends tree leaves, diff --git a/tests/linkml_runtime/test_utils/test_schemaview.py b/tests/linkml_runtime/test_utils/test_schemaview.py index fa467d82c3..13670ea424 100644 --- a/tests/linkml_runtime/test_utils/test_schemaview.py +++ b/tests/linkml_runtime/test_utils/test_schemaview.py @@ -664,6 +664,59 @@ def test_import_map_in_memory_dict_transitive(tmp_path: Path) -> None: assert view.induced_slot("leaf_attr", "RootClass").range == "string" +def _write_redirect_tree(tmp_path: Path, sub_imports: list[str]) -> Path: + """``root_dir/main.yaml`` imports ``sub``, which an importmap redirects to + ``elsewhere/sub.yaml``; ``sub``'s own imports must resolve next to it, not next to ``main``. + Returns the path to ``main.yaml``.""" + root_dir = tmp_path / "root_dir" + elsewhere = tmp_path / "elsewhere" + root_dir.mkdir() + elsewhere.mkdir() + (root_dir / "main.yaml").write_text( + "id: https://example.org/main\nname: main\ndefault_range: string\nimports: [sub]\n" + "classes:\n Thing:\n slots: [sub_slot]\n" + ) + (elsewhere / "sub.yaml").write_text( + f"id: https://example.org/sub\nname: sub\ndefault_range: string\nimports: {sub_imports}\nslots:\n sub_slot:\n" + ) + (elsewhere / "leaf.yaml").write_text( + "id: https://example.org/leaf\nname: leaf\ndefault_range: string\nslots:\n leaf_slot:\n" + ) + return root_dir / "main.yaml" + + +@pytest.mark.parametrize("importmap_value", ["../elsewhere/sub", "ABSOLUTE"]) +def test_importmap_redirected_schema_resolves_own_relative_imports(tmp_path: Path, importmap_value: str) -> None: + """A schema redirected outside the root tree by an importmap resolves its own relative + imports next to itself, not in the root schema's directory (#3499).""" + main = _write_redirect_tree(tmp_path, sub_imports=["./leaf"]) + if importmap_value == "ABSOLUTE": + importmap_value = str(tmp_path / "elsewhere" / "sub") + + view = SchemaView(str(main), importmap={"sub": importmap_value}) + slots = view.all_slots(imports=True) + assert "sub_slot" in slots + assert "leaf_slot" in slots + + +def test_importmap_redirected_schema_resolves_nested_relative_imports(tmp_path: Path) -> None: + """Relative imports resolve against the importing schema at every depth below a redirect.""" + main = _write_redirect_tree(tmp_path, sub_imports=["./nested/deep"]) + nested = tmp_path / "elsewhere" / "nested" + nested.mkdir() + (nested / "deep.yaml").write_text( + "id: https://example.org/deep\nname: deep\ndefault_range: string\nimports: [./deepest]\nslots:\n deep_slot:\n" + ) + (nested / "deepest.yaml").write_text( + "id: https://example.org/deepest\nname: deepest\ndefault_range: string\nslots:\n deepest_slot:\n" + ) + + view = SchemaView(str(main), importmap={"sub": "../elsewhere/sub"}) + slots = view.all_slots(imports=True) + assert "deep_slot" in slots + assert "deepest_slot" in slots + + def test_merge_imports_kwargs(schema_view_with_imports: SchemaView, sv_merged_imports_keyword: SchemaView) -> None: """Ensure that imports are or are not merged, depending on the kwargs.""" @@ -3878,3 +3931,36 @@ def test_annotation_dict_for_induced_slot() -> None: assert original_annots["bar"] == "some value" induced_annots = sv.annotation_dict("foo", class_name="TestClass") assert induced_annots["bar"] == "some other value" + + +def test_relative_import_in_url_imported_schema(tmp_path: Path) -> None: + """A relative import inside a schema imported by URL resolves against that URL. + + The importing schema's key is an absolute URL, which must not be normalised as a + filesystem path: doing so collapses the ``//`` of the scheme and the result is then + mistaken for a CURIE (#3499). + """ + modules = tmp_path / "modules" + modules.mkdir() + (modules / "leaf.yaml").write_text( + "id: https://example.org/leaf\nname: leaf\ndefault_range: string\nslots:\n leaf_slot:\n" + ) + (modules / "middle.yaml").write_text( + "id: https://example.org/middle\nname: middle\ndefault_range: string\n" + "imports: [./leaf]\nslots:\n middle_slot:\n" + ) + consumer = tmp_path / "consumer" + consumer.mkdir() + root = consumer / "root.yaml" + # as_uri() rather than an f-string: on Windows a bare path yields backslashes and only + # two slashes after the scheme, which is not a valid URIorCURIE and is rejected when the + # importing schema is loaded + middle_url = (modules / "middle").as_uri() + root.write_text( + "id: https://example.org/root\nname: root\ndefault_range: string\n" + f"imports: ['{middle_url}']\nclasses:\n Thing:\n slots: [middle_slot]\n" + ) + + slots = SchemaView(str(root)).all_slots(imports=True) + assert "middle_slot" in slots + assert "leaf_slot" in slots From 0cb65a8d0d9bcd5613d6b52af4eb897d9afc2ce8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:07:49 +0000 Subject: [PATCH 12/24] build(deps): bump platformdirs in the patch-updates group --- packages/linkml/pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index e06b5f3570..b1899aba2e 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "sphinx-click (>=6.0.0)", "openapi-spec-validator >= 0.8.4", "pydantic-settings>=2.15.0", - "platformdirs>=4.11.8", + "platformdirs>=4.11.10", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index d11999ff8f..a69c907f0f 100644 --- a/uv.lock +++ b/uv.lock @@ -2511,7 +2511,7 @@ requires-dist = [ { name = "openapi-spec-validator", specifier = ">=0.8.4" }, { name = "openpyxl" }, { name = "parse" }, - { name = "platformdirs", specifier = ">=4.11.8" }, + { name = "platformdirs", specifier = ">=4.11.10" }, { name = "prefixcommons", specifier = ">=0.1.7" }, { name = "prefixmaps", specifier = ">=0.2.2" }, { name = "pydantic", specifier = ">=2.13.5,<3.0.0" }, @@ -3673,11 +3673,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.9" +version = "4.11.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/b9/8adc4e1b422b27fd88540ec7bf1f406f77ef393ec070e26fc430e914cde8/platformdirs-4.11.9.tar.gz", hash = "sha256:e2c66a8d384596cd98e3c4aea2d761df7bac95d9d8a2cc3946daa8cdafdaebc1", size = 38345, upload-time = "2026-09-16T13:31:45.259Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/24/92d90bebedf197eb15b144367ce6fd4ad2de571927cd09dde190a36db8fc/platformdirs-4.11.10.tar.gz", hash = "sha256:9cd351c078ccf7dda1fdc5f8ccb9d8f5258984c63990e6df3627dde0b70b51d0", size = 39389, upload-time = "2026-09-18T01:45:15.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/94/803ba86705257d7eedddac4b02eb88a7483b1600e9200c1fefc6f1a9a3ff/platformdirs-4.11.9-py3-none-any.whl", hash = "sha256:0a3958f58a9e30321eaef0a424dd0b77cce242886b36b8aa992f9731ef2d59c1", size = 24472, upload-time = "2026-09-16T13:31:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/67/45/cae23ec98a49825d58de75c38cdb714605adeb6e6ab72a7ad2b28322892c/platformdirs-4.11.10-py3-none-any.whl", hash = "sha256:973c4c082b964958c71588448488d2b14fb253893dfbdb82325a14c84e540e8f", size = 24763, upload-time = "2026-09-18T01:45:13.878Z" }, ] [[package]] From b07d0a0fdd7d2e0bad6a8d53e314b70906b310b1 Mon Sep 17 00:00:00 2001 From: Corey Cox <69321580+amc-corey-cox@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:15:57 -0500 Subject: [PATCH 13/24] fix(jsonschemagen): keep classes closed by default, per the metamodel --- examples/tutorial/tutorial01/personinfo.json | 2 +- .../src/linkml/generators/jsonschemagen.py | 76 ++- .../src/linkml/generators/openapigen.py | 8 +- .../__snapshots__/biolink.schema.json | 618 +++++++++--------- .../test_generators/test_jsonschemagen.py | 113 ++++ .../test_issues/__snapshots__/issue_120.json | 4 +- .../test_issues/__snapshots__/issue_177.json | 6 +- .../__snapshots__/issue_202.json.schema | 2 +- .../test_issues/__snapshots__/issue_239.json | 2 +- .../test_issues/__snapshots__/issue_2499.json | 4 +- .../__snapshots__/genjsonschema/meta.json | 68 +- .../genjsonschema/meta_inline.json | 68 +- .../__snapshots__/genjsonschema/roottest.json | 6 +- .../genjsonschema/roottest2.json | 6 +- 14 files changed, 572 insertions(+), 411 deletions(-) diff --git a/examples/tutorial/tutorial01/personinfo.json b/examples/tutorial/tutorial01/personinfo.json index b4f0bc63f3..f83931ec6a 100644 --- a/examples/tutorial/tutorial01/personinfo.json +++ b/examples/tutorial/tutorial01/personinfo.json @@ -1,7 +1,7 @@ { "$defs": { "Person": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "age": { diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 82d508345a..1da7a24539 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -3,7 +3,7 @@ import logging import os from copy import deepcopy -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, cast import click @@ -414,8 +414,16 @@ class JsonSchemaGenerator(Generator, LifecycleMixin): # @deprecated("Use top_class") topClass: str | None = None - not_closed: bool | None = True - """If not closed, then an open-ended set of attributes can be instantiated for any object""" + not_closed: bool = False + """Allow data to include properties that the schema does not declare. + + Defaults to closed, following the metamodel: ``meta.yaml`` documents an absent + ``extra_slots`` as "forbid all additional data (default)". An explicit + ``extra_slots`` on a class always takes precedence over this. + + This governs classes only. The top-level schema takes ``additionalProperties`` + from the document's root class -- see :meth:`start_schema`. + """ indent: int = 4 @@ -424,6 +432,9 @@ class JsonSchemaGenerator(Generator, LifecycleMixin): top_class: ClassDefinitionName | str | None = None # JSON object is one instance of this """Class instantiated by the root node of the document tree""" + _root_class_name: str | None = field(default=None, init=False, repr=False) + """Name of the resolved root class, set by :meth:`start_schema`.""" + include_range_class_descendants: bool = False """If set, use an open world assumption and allow the range of a slot to be any descendant of the declared range. Note that if the range of a slot has a type designator, descendants will always be included. @@ -485,14 +496,49 @@ def __post_init__(self): if self.schemaview.get_class(self.top_class) is None: logger.warning(f"No class in schema named {self.top_class}") + def _names_match(self, a: str, b: str) -> bool: + """Compare class names the way ``--top-class`` has always been matched. + + ``top_class`` is habitually passed in CamelCase while the schema spells the + class out (``top_class="AnyType"`` for ``any type``), so the comparison is + on ``camelcase`` unless ``preserve_names`` is set. + """ + return a == b if self.preserve_names else camelcase(a) == camelcase(b) + + def _root_class(self) -> ClassDefinition | None: + """The class the root of the document instantiates, or ``None`` if there is none. + + Named by ``--top-class``, or failing that declared with ``tree_root: true``. + A schema may carry more than one ``tree_root`` (biolink-model does), so the + first is taken; the point is that every use site agrees on which it is. + """ + classes = self.schemaview.all_classes().values() + if self.top_class: + return next((c for c in classes if self._names_match(self.top_class, c.name)), None) + return next((c for c in classes if c.tree_root), None) + + def _is_root_class(self, cls: ClassDefinition) -> bool: + """Whether *cls* is the class the root of the document instantiates. + + Compares against the name resolved in :meth:`start_schema`, so the top-level + ``additionalProperties`` and the subschema merged beneath it cannot come from + two different classes. + """ + return self._root_class_name is not None and self._names_match(self._root_class_name, cls.name) + def start_schema(self, inline: bool = False): self.inline = inline - top_additional_properties = self.not_closed - if self.top_class: - top_class_def = self.schemaview.get_class(self.top_class) - if top_class_def is not None: - top_additional_properties = self.get_additional_properties(top_class_def) + root_class_def = self._root_class() + self._root_class_name = root_class_def.name if root_class_def is not None else None + + if root_class_def is not None: + top_additional_properties = self.get_additional_properties(root_class_def) + else: + # No root class means the top level has no properties of its own, so + # closing it would admit nothing but `{}`. Stay open regardless of + # `not_closed`, which governs classes. + top_additional_properties = True self.top_level_schema = JsonSchema( { @@ -619,13 +665,7 @@ def handle_class(self, cls: ClassDefinition) -> None: else: self.top_level_schema.add_def(cls.name, class_subschema) - if ( - self.top_class is not None - and ( - (self.preserve_names and self.top_class == cls.name) - or (not self.preserve_names and camelcase(self.top_class) == camelcase(cls.name)) - ) - ) or (self.top_class is None and cls.tree_root): + if self._is_root_class(cls): for key, value in class_subschema.items(): # check this first to ensure we don't overwrite things like additionalProperties # or description on the root. But we do want to copy over properties, required, @@ -1209,10 +1249,12 @@ def _parameterized_dimension(self, dimension: DimensionExpression, dtype: JsonSc ) @click.option( "--not-closed/--closed", - default=True, + default=False, show_default=True, help=""" -Set additionalProperties=False if closed otherwise true if not closed at the global level +Allow data to include properties that the schema does not declare. Closed by +default, following the metamodel; an explicit `extra_slots` on a class always +wins. The top level takes its value from the document's root class. """, ) @click.option( diff --git a/packages/linkml/src/linkml/generators/openapigen.py b/packages/linkml/src/linkml/generators/openapigen.py index 5865917287..999b14d4af 100644 --- a/packages/linkml/src/linkml/generators/openapigen.py +++ b/packages/linkml/src/linkml/generators/openapigen.py @@ -440,7 +440,13 @@ def serialize(self, template_file: str = "", **kwargs) -> str: # $defs. LinkML types are not part of $defs and are generated separately. # all_req_schemas contains all directly or transitively required schemas from # LinkML classes and types - json_schema = JsonSchemaGenerator(self.schemaview.schema, include_null=False, preserve_names=True).generate() + # not_closed=True is deliberate: APIs are extended backwards-compatibly by + # adding attributes to existing objects, which additionalProperties=False + # blocks. Stated explicitly rather than inherited from the generator default, + # which follows the metamodel and closes classes with no `extra_slots`. + json_schema = JsonSchemaGenerator( + self.schemaview.schema, include_null=False, preserve_names=True, not_closed=True + ).generate() all_req_schemas: dict[str, dict] = json.loads(json_schema.to_json())["$defs"] for linkml_name in req_linkml_names: if linkml_name in self.schemaview.all_types(): diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.schema.json b/tests/linkml/test_biolink_model/__snapshots__/biolink.schema.json index f1923d984c..d76f06623b 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.schema.json +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.schema.json @@ -1,7 +1,7 @@ { "$defs": { "AccessibleDnaRegion": { - "additionalProperties": true, + "additionalProperties": false, "description": "A region (or regions) of a chromatinized genome that has been measured to be more accessible to an enzyme such as DNase-I or Tn5 Transpose", "properties": { "category": { @@ -136,7 +136,7 @@ "type": "object" }, "Activity": { - "additionalProperties": true, + "additionalProperties": false, "description": "An activity is something that occurs over a period of time and acts upon or with entities; it may include consuming, processing, transforming, modifying, relocating, using, or generating entities.", "properties": { "category": { @@ -247,13 +247,13 @@ "type": "object" }, "ActivityAndBehavior": { - "additionalProperties": true, + "additionalProperties": false, "description": "Activity or behavior of any independent integral living, organization or mechanical actor in the world", "title": "ActivityAndBehavior", "type": "object" }, "AdministrativeEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -364,7 +364,7 @@ "type": "object" }, "Agent": { - "additionalProperties": true, + "additionalProperties": false, "description": "person, group, organization or project that provides a piece of information (i.e. a knowledge association)", "properties": { "address": { @@ -507,7 +507,7 @@ "type": "string" }, "AnatomicalEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "A subcellular location, cell type or gross anatomical part", "properties": { "category": { @@ -635,7 +635,7 @@ "type": "object" }, "AnatomicalEntityToAnatomicalEntityAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -1006,7 +1006,7 @@ "type": "object" }, "AnatomicalEntityToAnatomicalEntityOntogenicAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A relationship between two anatomical entities where the relationship is ontogenic, i.e. the two entities are related by development. A number of different relationship types can be used to specify the precise nature of the relationship.", "properties": { "adjusted_p_value": { @@ -1380,7 +1380,7 @@ "type": "object" }, "AnatomicalEntityToAnatomicalEntityPartOfAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A relationship between two anatomical entities where the relationship is mereological, i.e the two entities are related by parthood. This includes relationships between cellular components and cells, between cells and tissues, tissues and whole organisms", "properties": { "adjusted_p_value": { @@ -1760,7 +1760,7 @@ "type": "object" }, "Annotation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Biolink Model root class for entity annotations.", "title": "Annotation", "type": "object" @@ -1788,7 +1788,7 @@ "type": "string" }, "Article": { - "additionalProperties": true, + "additionalProperties": false, "description": "a piece of writing on a particular topic presented as a stand-alone section of a larger publication", "properties": { "authors": { @@ -2006,7 +2006,7 @@ "type": "object" }, "Association": { - "additionalProperties": true, + "additionalProperties": false, "description": "A typed association between two entities, supported by evidence", "properties": { "adjusted_p_value": { @@ -2377,7 +2377,7 @@ "type": "object" }, "Attribute": { - "additionalProperties": true, + "additionalProperties": false, "description": "A property or characteristic of an entity. For example, an apple may have properties such as color, shape, age, crispiness. An environmental sample may have attributes such as depth, lat, long, material.", "properties": { "category": { @@ -2510,7 +2510,7 @@ "type": "object" }, "Bacterium": { - "additionalProperties": true, + "additionalProperties": false, "description": "A member of a group of unicellular microorganisms lacking a nuclear membrane, that reproduce by binary fission and are often motile.", "properties": { "category": { @@ -2638,7 +2638,7 @@ "type": "object" }, "Behavior": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -2796,7 +2796,7 @@ "type": "object" }, "BehaviorToBehavioralFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between an mixture behavior and a behavioral feature manifested by the individual exhibited or has exhibited the behavior.", "properties": { "adjusted_p_value": { @@ -3304,7 +3304,7 @@ "type": "object" }, "BehavioralExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A behavioral exposure is a factor relating to behavior impacting an individual.", "properties": { "category": { @@ -3445,7 +3445,7 @@ "type": "object" }, "BehavioralFeature": { - "additionalProperties": true, + "additionalProperties": false, "description": "A phenotypic feature which is behavioral in nature.", "properties": { "category": { @@ -3573,13 +3573,13 @@ "type": "object" }, "BehavioralOutcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "An outcome resulting from an exposure event which is the manifestation of human behavior.", "title": "BehavioralOutcome", "type": "object" }, "BiologicalEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -3707,7 +3707,7 @@ "type": "object" }, "BiologicalProcess": { - "additionalProperties": true, + "additionalProperties": false, "description": "One or more causally connected executions of molecular functions", "properties": { "category": { @@ -3865,7 +3865,7 @@ "type": "object" }, "BiologicalProcessOrActivity": { - "additionalProperties": true, + "additionalProperties": false, "description": "Either an individual molecular activity, or a collection of causally connected molecular activities in a biological system.", "properties": { "category": { @@ -4023,7 +4023,7 @@ "type": "object" }, "BiologicalSex": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -4156,7 +4156,7 @@ "type": "object" }, "BioticExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "An external biotic exposure is an intake of (sometimes pathological) biological organisms (including viruses).", "properties": { "category": { @@ -4297,7 +4297,7 @@ "type": "object" }, "Book": { - "additionalProperties": true, + "additionalProperties": false, "description": "This class may rarely be instantiated except if use cases of a given knowledge graph support its utility.", "properties": { "authors": { @@ -4490,7 +4490,7 @@ "type": "object" }, "BookChapter": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "authors": { @@ -4701,7 +4701,7 @@ "type": "object" }, "Case": { - "additionalProperties": true, + "additionalProperties": false, "description": "An individual (human) organism that has a patient role in some clinical context.", "properties": { "category": { @@ -4829,7 +4829,7 @@ "type": "object" }, "CaseToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "An abstract association for use where the case is the subject", "properties": { "object": { @@ -4854,7 +4854,7 @@ "type": "object" }, "CaseToPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a case (e.g. individual patient) and a phenotypic feature in which the individual has or has had the phenotype.", "properties": { "adjusted_p_value": { @@ -5362,7 +5362,7 @@ "type": "object" }, "CausalGeneToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -5912,7 +5912,7 @@ "type": "string" }, "Cell": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -6040,7 +6040,7 @@ "type": "object" }, "CellLine": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -6168,7 +6168,7 @@ "type": "object" }, "CellLineAsAModelOfDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -6618,7 +6618,7 @@ "type": "object" }, "CellLineToDiseaseOrPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An relationship between a cell line and a disease or a phenotype, where the cell line is derived from an individual with that disease or phenotype.", "properties": { "adjusted_p_value": { @@ -6993,7 +6993,7 @@ "type": "object" }, "CellLineToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "An relationship between a cell line and another entity", "properties": { "object": { @@ -7018,7 +7018,7 @@ "type": "object" }, "CellularComponent": { - "additionalProperties": true, + "additionalProperties": false, "description": "A location in or around a cell", "properties": { "category": { @@ -7146,7 +7146,7 @@ "type": "object" }, "CellularOrganism": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -7274,7 +7274,7 @@ "type": "object" }, "ChemicalAffectsGeneAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Describes an effect that a chemical has on a gene or gene product (e.g. an impact of on its abundance, activity,localization, processing, expression, etc.)", "properties": { "adjusted_p_value": { @@ -7840,7 +7840,7 @@ "type": "object" }, "ChemicalEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "A chemical entity is a physical entity that pertains to chemistry or biochemistry.", "properties": { "available_from": { @@ -7992,7 +7992,7 @@ "type": "object" }, "ChemicalEntityAssessesNamedThingAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -8374,13 +8374,13 @@ "type": "string" }, "ChemicalEntityOrGeneOrGeneProduct": { - "additionalProperties": true, + "additionalProperties": false, "description": "A union of chemical entities and children, and gene or gene product. This mixin is helpful to use when searching across chemical entities that must include genes and their children as chemical entities.", "title": "ChemicalEntityOrGeneOrGeneProduct", "type": "object" }, "ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A regulatory relationship between two genes", "properties": { "adjusted_p_value": { @@ -8765,13 +8765,13 @@ "type": "object" }, "ChemicalEntityOrProteinOrPolypeptide": { - "additionalProperties": true, + "additionalProperties": false, "description": "A union of chemical entities and children, and protein and polypeptide. This mixin is helpful to use when searching across chemical entities that must include genes and their children as chemical entities.", "title": "ChemicalEntityOrProteinOrPolypeptide", "type": "object" }, "ChemicalEntityToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction between a chemical entity and another entity", "properties": { "object": { @@ -8796,7 +8796,7 @@ "type": "object" }, "ChemicalExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A chemical exposure is an intake of a particular chemical entity.", "properties": { "category": { @@ -8937,7 +8937,7 @@ "type": "object" }, "ChemicalGeneInteractionAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "describes a physical interaction between a chemical entity and a gene or gene product. Any biological or chemical effect resulting from such an interaction are out of scope, and covered by the ChemicalAffectsGeneAssociation type (e.g. impact of a chemical on the abundance, activity, structure, etc, of either participant in the interaction)", "properties": { "adjusted_p_value": { @@ -9409,7 +9409,7 @@ "type": "object" }, "ChemicalMixture": { - "additionalProperties": true, + "additionalProperties": false, "description": "A chemical mixture is a chemical entity composed of two or more molecular entities.", "properties": { "available_from": { @@ -9600,13 +9600,13 @@ "type": "object" }, "ChemicalOrDrugOrTreatment": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "title": "ChemicalOrDrugOrTreatment", "type": "object" }, "ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "This association defines a relationship between a chemical or treatment (or procedure) and a disease or phenotypic feature where the disesae or phenotypic feature is a secondary, typically (but not always) undesirable effect.", "properties": { "FDA_adverse_event_level": { @@ -10068,7 +10068,7 @@ "type": "object" }, "ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "This association defines a relationship between a chemical or treatment (or procedure) and a disease or phenotypic feature where the disease or phenotypic feature is a secondary undesirable effect.", "properties": { "FDA_adverse_event_level": { @@ -10544,7 +10544,7 @@ "type": "string" }, "ChemicalRole": { - "additionalProperties": true, + "additionalProperties": false, "description": "A role played by the molecular entity or part thereof within a chemical context.", "examples": [ "CHEBI:35469" @@ -10680,7 +10680,7 @@ "type": "object" }, "ChemicalToChemicalAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A relationship between two chemical entities. This can encompass actual interactions as well as temporal causal edges, e.g. one chemical converted to another.", "properties": { "adjusted_p_value": { @@ -11051,7 +11051,7 @@ "type": "object" }, "ChemicalToChemicalDerivationAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A causal relationship between two chemical entities, where the subject represents the upstream entity and the object represents the downstream. For any such association there is an implicit reaction: IF R has-input C1 AND R has-output C2 AND R enabled-by P AND R type Reaction THEN C1 derives-into C2 catalyst qualifier P", "properties": { "adjusted_p_value": { @@ -11436,7 +11436,7 @@ "type": "object" }, "ChemicalToDiseaseOrPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction between a chemical entity and a phenotype or disease, where the presence of the chemical gives rise to or exacerbates the phenotype.", "properties": { "adjusted_p_value": { @@ -11811,7 +11811,7 @@ "type": "object" }, "ChemicalToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction between a chemical entity and another entity", "properties": { "object": { @@ -11836,7 +11836,7 @@ "type": "object" }, "ChemicalToPathwayAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction between a chemical entity and a biological process or pathway.", "properties": { "adjusted_p_value": { @@ -12207,7 +12207,7 @@ "type": "object" }, "ChiSquaredAnalysisResult": { - "additionalProperties": true, + "additionalProperties": false, "description": "A result of a chi squared analysis.", "properties": { "category": { @@ -12357,7 +12357,7 @@ "type": "string" }, "ClinicalAttribute": { - "additionalProperties": true, + "additionalProperties": false, "description": "Attributes relating to a clinical manifestation", "properties": { "category": { @@ -12490,7 +12490,7 @@ "type": "object" }, "ClinicalCourse": { - "additionalProperties": true, + "additionalProperties": false, "description": "The course a disease typically takes from its onset, progression in time, and eventual resolution or death of the affected individual", "properties": { "category": { @@ -12623,7 +12623,7 @@ "type": "object" }, "ClinicalEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any entity or process that exists in the clinical domain and outside the biological realm. Diseases are placed under biological entities", "properties": { "category": { @@ -12734,7 +12734,7 @@ "type": "object" }, "ClinicalFinding": { - "additionalProperties": true, + "additionalProperties": false, "description": "this category is currently considered broad enough to tag clinical lab measurements and other biological attributes taken as 'clinical traits' with some statistical score, for example, a p value in genetic associations.", "properties": { "category": { @@ -12862,7 +12862,7 @@ "type": "object" }, "ClinicalIntervention": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -12973,7 +12973,7 @@ "type": "object" }, "ClinicalMeasurement": { - "additionalProperties": true, + "additionalProperties": false, "description": "A clinical measurement is a special kind of attribute which results from a laboratory observation from a subject individual or sample. Measurements can be connected to their subject by the 'has attribute' slot.", "properties": { "category": { @@ -13106,7 +13106,7 @@ "type": "object" }, "ClinicalModifier": { - "additionalProperties": true, + "additionalProperties": false, "description": "Used to characterize and specify the phenotypic abnormalities defined in the phenotypic abnormality sub-ontology, with respect to severity, laterality, and other aspects", "properties": { "category": { @@ -13239,7 +13239,7 @@ "type": "object" }, "ClinicalTrial": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -13350,7 +13350,7 @@ "type": "object" }, "CodingSequence": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -13485,7 +13485,7 @@ "type": "object" }, "Cohort": { - "additionalProperties": true, + "additionalProperties": false, "description": "A group of people banded together or treated as a group who share common characteristics. A cohort 'study' is a particular form of longitudinal study that samples a cohort, performing a cross-section at intervals through time.", "properties": { "category": { @@ -13613,7 +13613,7 @@ "type": "object" }, "CommonDataElement": { - "additionalProperties": true, + "additionalProperties": false, "description": "A Common Data Element (CDE) is a standardized, precisely defined question, paired with a set of allowable responses, used systematically across different sites, studies, or clinical trials to ensure consistent data collection. Multiple CDEs (from one or more Collections) can be curated into Forms. (https://cde.nlm.nih.gov/home)", "properties": { "category": { @@ -13750,7 +13750,7 @@ "type": "object" }, "ComplexChemicalExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A complex chemical exposure is an intake of a chemical mixture (e.g. gasoline), other than a drug.", "properties": { "category": { @@ -13883,7 +13883,7 @@ "type": "object" }, "ComplexMolecularMixture": { - "additionalProperties": true, + "additionalProperties": false, "description": "A complex molecular mixture is a chemical mixture composed of two or more molecular entities with unknown concentration and stoichiometry.", "properties": { "available_from": { @@ -14074,7 +14074,7 @@ "type": "object" }, "ConceptCountAnalysisResult": { - "additionalProperties": true, + "additionalProperties": false, "description": "A result of a concept count analysis.", "properties": { "category": { @@ -14211,7 +14211,7 @@ "type": "object" }, "ConfidenceLevel": { - "additionalProperties": true, + "additionalProperties": false, "description": "Level of confidence in a statement", "properties": { "category": { @@ -14348,7 +14348,7 @@ "type": "object" }, "ContributorAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any association between an entity (such as a publication) and various agents that contribute to its realisation", "properties": { "adjusted_p_value": { @@ -14726,7 +14726,7 @@ "type": "object" }, "CorrelatedGeneToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -15251,7 +15251,7 @@ "type": "object" }, "Dataset": { - "additionalProperties": true, + "additionalProperties": false, "description": "an item that refers to a collection of data from a data source.", "properties": { "category": { @@ -15388,7 +15388,7 @@ "type": "object" }, "DatasetDistribution": { - "additionalProperties": true, + "additionalProperties": false, "description": "an item that holds distribution level information about a dataset.", "properties": { "category": { @@ -15531,7 +15531,7 @@ "type": "object" }, "DatasetSummary": { - "additionalProperties": true, + "additionalProperties": false, "description": "an item that holds summary level information about a dataset.", "properties": { "category": { @@ -15680,7 +15680,7 @@ "type": "object" }, "DatasetVersion": { - "additionalProperties": true, + "additionalProperties": false, "description": "an item that holds version level information about a dataset.", "properties": { "category": { @@ -15835,7 +15835,7 @@ "type": "object" }, "Device": { - "additionalProperties": true, + "additionalProperties": false, "description": "A thing made or adapted for a particular purpose, especially a piece of mechanical or electronic equipment", "properties": { "category": { @@ -15946,7 +15946,7 @@ "type": "object" }, "DiagnosticAid": { - "additionalProperties": true, + "additionalProperties": false, "description": "A device or substance used to help diagnose disease or injury", "properties": { "category": { @@ -16068,7 +16068,7 @@ "type": "string" }, "Disease": { - "additionalProperties": true, + "additionalProperties": false, "description": "A disorder of structure or function, especially one that produces specific signs, phenotypes or symptoms or that affects a specific location and is not simply a direct result of physical injury. A disposition to undergo pathological processes that exists in an organism because of one or more disorders in that organism.", "properties": { "category": { @@ -16196,7 +16196,7 @@ "type": "object" }, "DiseaseOrPhenotypicFeature": { - "additionalProperties": true, + "additionalProperties": false, "description": "Either one of a disease or an individual phenotypic feature. Some knowledge resources such as Monarch treat these as distinct, others such as MESH conflate. Please see definitions of phenotypic feature and disease in this model for their independent descriptions. This class is helpful to enforce domains and ranges that may involve either a disease or a phenotypic feature.", "properties": { "category": { @@ -16324,7 +16324,7 @@ "type": "object" }, "DiseaseOrPhenotypicFeatureExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A disease or phenotypic feature state, when viewed as an exposure, represents an precondition, leading to or influencing an outcome, e.g. HIV predisposing an individual to infections; a relative deficiency of skin pigmentation predisposing an individual to skin cancer.", "properties": { "category": { @@ -16465,13 +16465,13 @@ "type": "object" }, "DiseaseOrPhenotypicFeatureOutcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "Physiological outcomes resulting from an exposure event which is the manifestation of a disease or other characteristic phenotype.", "title": "DiseaseOrPhenotypicFeatureOutcome", "type": "object" }, "DiseaseOrPhenotypicFeatureToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "object": { @@ -16500,7 +16500,7 @@ "type": "object" }, "DiseaseOrPhenotypicFeatureToGeneticInheritanceAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between either a disease or a phenotypic feature and its mode of (genetic) inheritance.", "properties": { "adjusted_p_value": { @@ -16881,7 +16881,7 @@ "type": "object" }, "DiseaseOrPhenotypicFeatureToLocationAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between either a disease or a phenotypic feature and an anatomical entity, where the disease/feature manifests in that site.", "properties": { "adjusted_p_value": { @@ -17259,7 +17259,7 @@ "type": "object" }, "DiseaseToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "object": { @@ -17287,7 +17287,7 @@ "type": "object" }, "DiseaseToExposureEventAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between an exposure event and a disease.", "properties": { "adjusted_p_value": { @@ -17661,7 +17661,7 @@ "type": "object" }, "DiseaseToPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a disease and a phenotypic feature in which the phenotypic feature is associated with the disease in some way.", "properties": { "adjusted_p_value": { @@ -18179,7 +18179,7 @@ "type": "object" }, "Drug": { - "additionalProperties": true, + "additionalProperties": false, "description": "A substance intended for use in the diagnosis, cure, mitigation, treatment, or prevention of disease", "properties": { "available_from": { @@ -18390,7 +18390,7 @@ "type": "string" }, "DrugExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A drug exposure is an intake of a particular drug.", "properties": { "category": { @@ -18531,7 +18531,7 @@ "type": "object" }, "DrugLabel": { - "additionalProperties": true, + "additionalProperties": false, "description": "a document accompanying a drug or its container that provides written, printed or graphic information about the drug, including drug contents, specific instructions or warnings for administration, storage and disposal instructions, etc.", "properties": { "authors": { @@ -18723,7 +18723,7 @@ "type": "object" }, "DrugToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction between a drug and another entity", "properties": { "object": { @@ -18748,7 +18748,7 @@ "type": "object" }, "DrugToGeneAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction between a drug and a gene or gene product.", "properties": { "adjusted_p_value": { @@ -19119,7 +19119,7 @@ "type": "object" }, "DrugToGeneInteractionExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "drug to gene interaction exposure is a drug exposure is where the interactions of the drug with specific genes are known to constitute an 'exposure' to the organism, leading to or influencing an outcome.", "properties": { "category": { @@ -19281,7 +19281,7 @@ "type": "string" }, "DruggableGeneToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -19797,7 +19797,7 @@ "type": "object" }, "Entity": { - "additionalProperties": true, + "additionalProperties": false, "description": "Root Biolink Model class for all things and informational relationships, real or imagined.", "properties": { "category": { @@ -19873,7 +19873,7 @@ "type": "object" }, "EntityToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -20265,7 +20265,7 @@ "type": "object" }, "EntityToDiseaseAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "mixin class for any association whose object (target node) is a disease", "properties": { "disease_context_qualifier": { @@ -20366,7 +20366,7 @@ "type": "object" }, "EntityToDiseaseOrPhenotypicFeatureAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "object": { @@ -20395,7 +20395,7 @@ "type": "object" }, "EntityToExposureEventAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between some entity and an exposure event.", "properties": { "object": { @@ -20420,7 +20420,7 @@ "type": "object" }, "EntityToFeatureOrDiseaseQualifiersMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "Qualifiers for entity to disease or phenotype associations.", "properties": { "disease_context_qualifier": { @@ -20518,7 +20518,7 @@ "type": "object" }, "EntityToOutcomeAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between some entity and an outcome", "properties": { "object": { @@ -20543,7 +20543,7 @@ "type": "object" }, "EntityToPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -20935,7 +20935,7 @@ "type": "object" }, "EntityToPhenotypicFeatureAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "anatomical_context_qualifier": { @@ -21097,7 +21097,7 @@ "type": "object" }, "EnvironmentalExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A environmental exposure is a factor relating to abiotic processes in the environment including sunlight (UV-B), atmospheric (heat, cold, general pollution) and water-born contaminants.", "properties": { "category": { @@ -21238,7 +21238,7 @@ "type": "object" }, "EnvironmentalFeature": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -21349,7 +21349,7 @@ "type": "object" }, "EnvironmentalFoodContaminant": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "available_from": { @@ -21501,7 +21501,7 @@ "type": "object" }, "EnvironmentalProcess": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -21612,13 +21612,13 @@ "type": "object" }, "EpidemiologicalOutcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "An epidemiological outcome, such as societal disease burden, resulting from an exposure event.", "title": "EpidemiologicalOutcome", "type": "object" }, "EpigenomicEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "has_biological_sequence": { @@ -21633,7 +21633,7 @@ "type": "object" }, "Event": { - "additionalProperties": true, + "additionalProperties": false, "description": "Something that happens at a given place and time.", "properties": { "category": { @@ -21744,7 +21744,7 @@ "type": "object" }, "EvidenceType": { - "additionalProperties": true, + "additionalProperties": false, "description": "Class of evidence that supports an association", "properties": { "category": { @@ -21881,7 +21881,7 @@ "type": "object" }, "Exon": { - "additionalProperties": true, + "additionalProperties": false, "description": "A region of the transcript sequence within a gene which is not removed from the primary RNA transcript by RNA splicing.", "properties": { "category": { @@ -22009,7 +22009,7 @@ "type": "object" }, "ExonToTranscriptRelationship": { - "additionalProperties": true, + "additionalProperties": false, "description": "A transcript is formed from multiple exons", "properties": { "adjusted_p_value": { @@ -22380,7 +22380,7 @@ "type": "object" }, "ExposureEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "A (possibly time bounded) incidence of a feature of the environment of an organism that influences one or more phenotypic features of that organism, potentially mediated by genes", "properties": { "id": { @@ -22403,7 +22403,7 @@ "type": "object" }, "ExposureEventToOutcomeAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between an exposure event and an outcome.", "properties": { "adjusted_p_value": { @@ -22789,7 +22789,7 @@ "type": "object" }, "ExposureEventToPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any association between an environment and a phenotypic feature, where being in the environment influences the phenotype.", "properties": { "adjusted_p_value": { @@ -23308,7 +23308,7 @@ "type": "string" }, "FeatureOrDiseaseQualifiersToEntityMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "Qualifiers for disease or phenotype to entity associations.", "properties": { "frequency_qualifier": { @@ -23395,7 +23395,7 @@ "type": "object" }, "Food": { - "additionalProperties": true, + "additionalProperties": false, "description": "A substance consumed by a living organism as a source of nutrition", "properties": { "available_from": { @@ -23586,7 +23586,7 @@ "type": "object" }, "FoodAdditive": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "available_from": { @@ -23738,7 +23738,7 @@ "type": "object" }, "FrequencyQualifierMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "Qualifier for frequency type associations", "properties": { "frequency_qualifier": { @@ -23770,7 +23770,7 @@ "type": "object" }, "FrequencyQuantifier": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "has_count": { @@ -23805,7 +23805,7 @@ "type": "object" }, "FunctionalAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a macromolecular machine mixin (gene, gene product or complex of gene products) and either a molecular activity, a biological process or a cellular location in which a function is executed.", "properties": { "adjusted_p_value": { @@ -24183,7 +24183,7 @@ "type": "object" }, "Fungus": { - "additionalProperties": true, + "additionalProperties": false, "description": "A kingdom of eukaryotic, heterotrophic organisms that live as saprobes or parasites, including mushrooms, yeasts, smuts, molds, etc. They reproduce either sexually or asexually, and have life cycles that range from simple to complex. Filamentous fungi refer to those that grow as multicellular colonies (mushrooms and molds).", "properties": { "category": { @@ -24311,7 +24311,7 @@ "type": "object" }, "Gene": { - "additionalProperties": true, + "additionalProperties": false, "description": "A region (or regions) that includes all of the sequence elements necessary to encode a functional transcript. A gene locus may include regulatory regions, transcribed regions and/or other functional sequence regions.", "properties": { "category": { @@ -24453,7 +24453,7 @@ "type": "object" }, "GeneAffectsChemicalAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Describes an effect that a gene or gene product has on a chemical entity (e.g. an impact of on its abundance, activity, localization, processing, transport, etc.)", "examples": [ "JsonObj(subject='TRPC4', predicate='affects', qualified_predicte='causes', object='Barium', object_aspect_qualifier='transport', object_direction_qualifier='increased')" @@ -25032,7 +25032,7 @@ "type": "object" }, "GeneAsAModelOfDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -25548,7 +25548,7 @@ "type": "object" }, "GeneExpressionMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "Observed gene expression intensity, context (site, stage) and associated phenotypic status within which the expression occurs.", "properties": { "expression_site": { @@ -25590,7 +25590,7 @@ "type": "object" }, "GeneFamily": { - "additionalProperties": true, + "additionalProperties": false, "description": "any grouping of multiple genes or gene products related by common descent", "properties": { "category": { @@ -25728,7 +25728,7 @@ "type": "object" }, "GeneGroupingMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "any grouping of multiple genes or gene products", "properties": { "has_gene_or_gene_product": { @@ -25746,7 +25746,7 @@ "type": "object" }, "GeneHasVariantThatContributesToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -26277,7 +26277,7 @@ "type": "object" }, "GeneOrGeneProduct": { - "additionalProperties": true, + "additionalProperties": false, "description": "A union of gene loci or gene products. Frequently an identifier for one will be used as proxy for another", "properties": { "name": { @@ -26367,7 +26367,7 @@ "type": "string" }, "GeneProductIsoformMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "This is an abstract class that can be mixed in with different kinds of gene products to indicate that the gene product is intended to represent a specific isoform rather than a canonical or reference or generic product. The designation of canonical or reference may be arbitrary, or it may represent the superclass of all isoforms.", "properties": { "name": { @@ -26402,7 +26402,7 @@ "type": "object" }, "GeneProductMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "The functional molecular product of a single gene locus. Gene products are either proteins or functional RNA molecules.", "properties": { "name": { @@ -26437,7 +26437,7 @@ "type": "object" }, "GeneRegulatesGeneAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Describes a regulatory relationship between two genes or gene products.", "examples": [ "JsonObj(subject='NCBIGene:551', predicate='regulates', qualified_predicte='causes', object='NCBIGene:1636', object_aspect_qualifier='activity_or_abundance', object_direction_qualifier='downregulated')" @@ -26846,7 +26846,7 @@ "type": "object" }, "GeneToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -27371,7 +27371,7 @@ "type": "object" }, "GeneToDiseaseOrPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -27898,7 +27898,7 @@ "type": "object" }, "GeneToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "object": { @@ -27923,7 +27923,7 @@ "type": "object" }, "GeneToExpressionSiteAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a gene and a gene expression site, possibly qualified by stage/timing info.", "properties": { "adjusted_p_value": { @@ -28317,7 +28317,7 @@ "type": "object" }, "GeneToGeneAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "abstract parent class for different kinds of gene-gene or gene product to gene product relationships. Includes homology and interaction.", "properties": { "adjusted_p_value": { @@ -28688,7 +28688,7 @@ "type": "object" }, "GeneToGeneCoexpressionAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Indicates that two genes are co-expressed, generally under the same conditions.", "properties": { "adjusted_p_value": { @@ -29096,7 +29096,7 @@ "type": "object" }, "GeneToGeneFamilyAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Set membership of a gene in a family of genes related by common evolutionary ancestry usually inferred by sequence comparisons. The genes in a given family generally share common sequence motifs which generally map onto shared gene product structure-function relationships.", "properties": { "adjusted_p_value": { @@ -29470,7 +29470,7 @@ "type": "object" }, "GeneToGeneHomologyAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A homology association between two genes. May be orthology (in which case the species of subject and object should differ) or paralogy (in which case the species may be the same)", "properties": { "adjusted_p_value": { @@ -29847,7 +29847,7 @@ "type": "object" }, "GeneToGeneProductRelationship": { - "additionalProperties": true, + "additionalProperties": false, "description": "A gene is transcribed and potentially translated to a gene product", "properties": { "adjusted_p_value": { @@ -30221,7 +30221,7 @@ "type": "object" }, "GeneToGoTermAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -30598,7 +30598,7 @@ "type": "object" }, "GeneToPathwayAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction between a gene or gene product and a biological process or pathway.", "properties": { "adjusted_p_value": { @@ -30969,7 +30969,7 @@ "type": "object" }, "GeneToPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -31496,7 +31496,7 @@ "type": "object" }, "GeneticInheritance": { - "additionalProperties": true, + "additionalProperties": false, "description": "The pattern or 'mode' in which a particular genetic trait or disorder is passed from one generation to the next, e.g. autosomal dominant, autosomal recessive, etc.", "properties": { "category": { @@ -31624,7 +31624,7 @@ "type": "object" }, "Genome": { - "additionalProperties": true, + "additionalProperties": false, "description": "A genome is the sum of genetic material within a cell or virion.", "properties": { "category": { @@ -31759,7 +31759,7 @@ "type": "object" }, "GenomicBackgroundExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A genomic background exposure is where an individual's specific genomic background of genes, sequence variants or other pre-existing genomic conditions constitute a kind of 'exposure' to the organism, leading to or influencing an outcome.", "properties": { "category": { @@ -31934,7 +31934,7 @@ "type": "object" }, "GenomicEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "has_biological_sequence": { @@ -31949,7 +31949,7 @@ "type": "object" }, "GenomicSequenceLocalization": { - "additionalProperties": true, + "additionalProperties": false, "description": "A relationship between a sequence feature and a nucleic acid entity it is localized to. The reference entity may be a chromosome, chromosome region or information entity such as a contig.", "properties": { "adjusted_p_value": { @@ -32370,7 +32370,7 @@ "type": "object" }, "Genotype": { - "additionalProperties": true, + "additionalProperties": false, "description": "An information content entity that describes a genome by specifying the total variation in genomic sequence and/or gene expression, relative to some established background", "properties": { "category": { @@ -32511,7 +32511,7 @@ "type": "object" }, "GenotypeAsAModelOfDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -32961,7 +32961,7 @@ "type": "object" }, "GenotypeToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -33411,7 +33411,7 @@ "type": "object" }, "GenotypeToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "object": { @@ -33436,7 +33436,7 @@ "type": "object" }, "GenotypeToGeneAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any association between a genotype and a gene. The genotype have have multiple variants in that gene or a single one. There is no assumption of cardinality", "properties": { "adjusted_p_value": { @@ -33807,7 +33807,7 @@ "type": "object" }, "GenotypeToGenotypePartAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any association between one genotype and a genotypic entity that is a sub-component of it", "properties": { "adjusted_p_value": { @@ -34181,7 +34181,7 @@ "type": "object" }, "GenotypeToPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any association between one genotype and a phenotypic feature, where having the genotype confers the phenotype, either in isolation or through environment", "properties": { "adjusted_p_value": { @@ -34692,7 +34692,7 @@ "type": "object" }, "GenotypeToVariantAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any association between a genotype and a sequence variant.", "properties": { "adjusted_p_value": { @@ -35063,7 +35063,7 @@ "type": "object" }, "GenotypicSex": { - "additionalProperties": true, + "additionalProperties": false, "description": "An attribute corresponding to the genotypic sex of the individual, based upon genotypic composition of sex chromosomes.", "properties": { "category": { @@ -35196,7 +35196,7 @@ "type": "object" }, "GeographicExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A geographic exposure is a factor relating to geographic proximity to some impactful entity.", "properties": { "category": { @@ -35337,7 +35337,7 @@ "type": "object" }, "GeographicLocation": { - "additionalProperties": true, + "additionalProperties": false, "description": "a location that can be described in lat/long coordinates", "properties": { "category": { @@ -35462,7 +35462,7 @@ "type": "object" }, "GeographicLocationAtTime": { - "additionalProperties": true, + "additionalProperties": false, "description": "a location that can be described in lat/long coordinates, for a particular time", "properties": { "category": { @@ -35595,7 +35595,7 @@ "type": "object" }, "GrossAnatomicalStructure": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -35723,7 +35723,7 @@ "type": "object" }, "Haplotype": { - "additionalProperties": true, + "additionalProperties": false, "description": "A set of zero or more Alleles on a single instance of a Sequence[VMC]", "properties": { "category": { @@ -35858,7 +35858,7 @@ "type": "object" }, "Hospitalization": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -35969,13 +35969,13 @@ "type": "object" }, "HospitalizationOutcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "An outcome resulting from an exposure event which is the increased manifestation of acute (e.g. emergency room visit) or chronic (inpatient) hospitalization.", "title": "HospitalizationOutcome", "type": "object" }, "Human": { - "additionalProperties": true, + "additionalProperties": false, "description": "A member of the the species Homo sapiens.", "properties": { "category": { @@ -36103,7 +36103,7 @@ "type": "object" }, "IndividualOrganism": { - "additionalProperties": true, + "additionalProperties": false, "description": "An instance of an organism. For example, Richard Nixon, Charles Darwin, my pet cat. Example ID: ORCID:0000-0002-5355-2576", "properties": { "category": { @@ -36231,7 +36231,7 @@ "type": "object" }, "InformationContentEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "a piece of information that typically describes some topic of discourse or is used as support.", "properties": { "category": { @@ -36368,7 +36368,7 @@ "type": "object" }, "InformationContentEntityToNamedThingAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "association between a named thing and a information content entity where the specific context of the relationship between that named thing and the publication is unknown. For example, model organisms databases often capture the knowledge that a gene is found in a journal article, but not specifically the context in which that gene was documented in the article. In these cases, this association with the accompanying predicate 'mentions' could be used. Conversely, for more specific associations (like 'gene to disease association', the publication should be captured as an edge property).", "properties": { "adjusted_p_value": { @@ -36742,7 +36742,7 @@ "type": "object" }, "Invertebrate": { - "additionalProperties": true, + "additionalProperties": false, "description": "An animal lacking a vertebral column. This group consists of 98% of all animal species.", "properties": { "category": { @@ -36870,7 +36870,7 @@ "type": "object" }, "JournalArticle": { - "additionalProperties": true, + "additionalProperties": false, "description": "an article, typically presenting results of research, that is published in an issue of a scientific journal.", "properties": { "authors": { @@ -37101,7 +37101,7 @@ "type": "string" }, "LifeStage": { - "additionalProperties": true, + "additionalProperties": false, "description": "A stage of development or growth of an organism, including post-natal adult stages", "properties": { "category": { @@ -37229,7 +37229,7 @@ "type": "object" }, "LogOddsAnalysisResult": { - "additionalProperties": true, + "additionalProperties": false, "description": "A result of a log odds ratio analysis.", "properties": { "category": { @@ -37376,7 +37376,7 @@ "type": "string" }, "MacromolecularComplex": { - "additionalProperties": true, + "additionalProperties": false, "description": "A stable assembly of two or more macromolecules, i.e. proteins, nucleic acids, carbohydrates or lipids, in which at least one component is a protein and the constituent parts function together.", "properties": { "category": { @@ -37504,7 +37504,7 @@ "type": "object" }, "MacromolecularMachineMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "A union of gene locus, gene product, and macromolecular complex. These are the basic units of function in a cell. They either carry out individual biological activities, or they encode molecules which do this.", "properties": { "name": { @@ -37519,7 +37519,7 @@ "type": "object" }, "MacromolecularMachineToBiologicalProcessAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a biological process or pathway (as represented in the GO biological process branch), where the entity carries out some part of the process, regulates it, or acts upstream of it.", "properties": { "adjusted_p_value": { @@ -37908,7 +37908,7 @@ "type": "object" }, "MacromolecularMachineToCellularComponentAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a cellular component (as represented in the GO cellular component branch), where the entity carries out its function in the cellular component.", "properties": { "adjusted_p_value": { @@ -38297,7 +38297,7 @@ "type": "object" }, "MacromolecularMachineToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "an association which has a macromolecular machine mixin as a subject", "properties": { "object": { @@ -38333,7 +38333,7 @@ "type": "object" }, "MacromolecularMachineToMolecularActivityAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a molecular activity (as represented in the GO molecular function branch), where the entity carries out the activity, or contributes to its execution.", "properties": { "adjusted_p_value": { @@ -38722,7 +38722,7 @@ "type": "object" }, "Mammal": { - "additionalProperties": true, + "additionalProperties": false, "description": "A member of the class Mammalia, a clade of endothermic amniotes distinguished from reptiles and birds by the possession of hair, three middle ear bones, mammary glands, and a neocortex", "properties": { "category": { @@ -38850,7 +38850,7 @@ "type": "object" }, "MappingCollection": { - "additionalProperties": true, + "additionalProperties": false, "description": "A collection of deprecated mappings.", "properties": { "predicate_mappings": { @@ -38868,7 +38868,7 @@ "type": "object" }, "MaterialSample": { - "additionalProperties": true, + "additionalProperties": false, "description": "A sample is a limited quantity of something (e.g. an individual or set of individuals from a population, or a portion of a substance) to be used for testing, analysis, inspection, investigation, demonstration, or trial use. [SIO]", "properties": { "category": { @@ -38979,7 +38979,7 @@ "type": "object" }, "MaterialSampleDerivationAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a material sample and the material entity from which it is derived.", "properties": { "adjusted_p_value": { @@ -39354,7 +39354,7 @@ "type": "object" }, "MaterialSampleToDiseaseOrPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a material sample and a disease or phenotype.", "properties": { "adjusted_p_value": { @@ -39729,7 +39729,7 @@ "type": "object" }, "MaterialSampleToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a material sample and something.", "properties": { "object": { @@ -39754,7 +39754,7 @@ "type": "object" }, "MicroRNA": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -39882,7 +39882,7 @@ "type": "object" }, "ModelToDiseaseAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "This mixin is used for any association class for which the subject (source node) plays the role of a 'model', in that it recapitulates some features of the disease in a way that is useful for studying the disease outside a patient carrying the disease", "properties": { "object": { @@ -39910,7 +39910,7 @@ "type": "object" }, "MolecularActivity": { - "additionalProperties": true, + "additionalProperties": false, "description": "An execution of a molecular function carried out by a gene product or macromolecular complex.", "properties": { "category": { @@ -40068,7 +40068,7 @@ "type": "object" }, "MolecularActivityToChemicalEntityAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Added in response to capturing relationship between microbiome activities as measured via measurements of blood analytes as collected via blood and stool samples", "properties": { "adjusted_p_value": { @@ -40439,7 +40439,7 @@ "type": "object" }, "MolecularActivityToMolecularActivityAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Added in response to capturing relationship between microbiome activities as measured via measurements of blood analytes as collected via blood and stool samples", "properties": { "adjusted_p_value": { @@ -40810,7 +40810,7 @@ "type": "object" }, "MolecularActivityToPathwayAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Association that holds the relationship between a reaction and the pathway it participates in.", "properties": { "adjusted_p_value": { @@ -41190,7 +41190,7 @@ "type": "object" }, "MolecularEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "A molecular entity is a chemical entity composed of individual or covalently bonded atoms.", "properties": { "available_from": { @@ -41349,7 +41349,7 @@ "type": "object" }, "MolecularMixture": { - "additionalProperties": true, + "additionalProperties": false, "description": "A molecular mixture is a chemical mixture composed of two or more molecular entities with known concentration and stoichiometry.", "properties": { "available_from": { @@ -41540,13 +41540,13 @@ "type": "object" }, "MortalityOutcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "An outcome of death from resulting from an exposure event.", "title": "MortalityOutcome", "type": "object" }, "NamedThing": { - "additionalProperties": true, + "additionalProperties": false, "description": "a databased entity or concept/class", "properties": { "category": { @@ -41657,7 +41657,7 @@ "type": "object" }, "NamedThingAssociatedWithLikelihoodOfNamedThingAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -42097,7 +42097,7 @@ "type": "object" }, "NoncodingRNAProduct": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -42225,7 +42225,7 @@ "type": "object" }, "NucleicAcidEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "A nucleic acid entity is a molecular entity characterized by availability in gene databases of nucleotide-based sequence representations of its precise sequence; for convenience of representation, partial sequences of various kinds are included.", "properties": { "available_from": { @@ -42408,7 +42408,7 @@ "type": "object" }, "NucleicAcidSequenceMotif": { - "additionalProperties": true, + "additionalProperties": false, "description": "A linear nucleotide sequence pattern that is widespread and has, or is conjectured to have, a biological significance. e.g. the TATA box promoter motif, transcription factor binding consensus sequences.", "properties": { "category": { @@ -42536,7 +42536,7 @@ "type": "object" }, "NucleosomeModification": { - "additionalProperties": true, + "additionalProperties": false, "description": "A chemical modification of a histone protein within a nucleosome octomer or a substitution of a histone with a variant histone isoform. e.g. Histone 4 Lysine 20 methylation (H4K20me), histone variant H2AZ substituting H2A.", "properties": { "category": { @@ -42671,7 +42671,7 @@ "type": "object" }, "ObservedExpectedFrequencyAnalysisResult": { - "additionalProperties": true, + "additionalProperties": false, "description": "A result of a observed expected frequency analysis.", "properties": { "category": { @@ -42808,13 +42808,13 @@ "type": "object" }, "Occurrent": { - "additionalProperties": true, + "additionalProperties": false, "description": "A processual entity.", "title": "Occurrent", "type": "object" }, "Onset": { - "additionalProperties": true, + "additionalProperties": false, "description": "The age group in which (disease) symptom manifestations appear.", "properties": { "category": { @@ -42947,7 +42947,7 @@ "type": "object" }, "OntologyClass": { - "additionalProperties": true, + "additionalProperties": false, "description": "a concept or class in an ontology, vocabulary or thesaurus. Note that nodes in a biolink compatible KG can be considered both instances of biolink classes, and OWL classes in their own right. In general you should not need to use this class directly. Instead, use the appropriate biolink class. For example, for the GO concept of endocytosis (GO:0006897), use bl:BiologicalProcess as the type.", "examples": [ "UBERON:0000955" @@ -42965,7 +42965,7 @@ "type": "object" }, "OrganismAttribute": { - "additionalProperties": true, + "additionalProperties": false, "description": "describes a characteristic of an organismal entity.", "properties": { "category": { @@ -43098,7 +43098,7 @@ "type": "object" }, "OrganismTaxon": { - "additionalProperties": true, + "additionalProperties": false, "description": "A classification of a set of organisms. Example instances: NCBITaxon:9606 (Homo sapiens), NCBITaxon:2 (Bacteria). Can also be used to represent strains or subspecies.", "properties": { "category": { @@ -43215,7 +43215,7 @@ "type": "object" }, "OrganismTaxonToEntityAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between an organism taxon and another entity", "properties": { "object": { @@ -43240,7 +43240,7 @@ "type": "object" }, "OrganismTaxonToEnvironmentAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -43611,7 +43611,7 @@ "type": "object" }, "OrganismTaxonToOrganismTaxonAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A relationship between two organism taxon nodes", "properties": { "adjusted_p_value": { @@ -43982,7 +43982,7 @@ "type": "object" }, "OrganismTaxonToOrganismTaxonInteraction": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction relationship between two taxa. This may be a symbiotic relationship (encompassing mutualism and parasitism), or it may be non-symbiotic. Example: plague transmitted_by flea; cattle domesticated_by Homo sapiens; plague infects Homo sapiens", "properties": { "adjusted_p_value": { @@ -44371,7 +44371,7 @@ "type": "object" }, "OrganismTaxonToOrganismTaxonSpecialization": { - "additionalProperties": true, + "additionalProperties": false, "description": "A child-parent relationship between two taxa. For example: Homo sapiens subclass_of Homo", "properties": { "adjusted_p_value": { @@ -44745,7 +44745,7 @@ "type": "object" }, "OrganismToOrganismAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -45116,7 +45116,7 @@ "type": "object" }, "OrganismalEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "A named entity that is either a part of an organism, a whole organism, population or clade of organisms, excluding chemical entities", "properties": { "category": { @@ -45244,7 +45244,7 @@ "type": "object" }, "OrganismalEntityAsAModelOfDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -45694,13 +45694,13 @@ "type": "object" }, "Outcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "An entity that has the role of being the consequence of an exposure event. This is an abstract mixin grouping of various categories of possible biological or non-biological (e.g. clinical) outcomes.", "title": "Outcome", "type": "object" }, "PairwiseGeneToGeneInteraction": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction between two genes or two gene products. May be physical (e.g. protein binding) or genetic (between genes). May be symmetric (e.g. protein interaction) or directed (e.g. phosphorylation)", "properties": { "adjusted_p_value": { @@ -46082,7 +46082,7 @@ "type": "object" }, "PairwiseMolecularInteraction": { - "additionalProperties": true, + "additionalProperties": false, "description": "An interaction at the molecular level between two physical entities", "properties": { "adjusted_p_value": { @@ -46479,7 +46479,7 @@ "type": "object" }, "Patent": { - "additionalProperties": true, + "additionalProperties": false, "description": "a legal document granted by a patent issuing authority which confers upon the patenter the sole right to make, use and sell an invention for a set period of time.", "properties": { "authors": { @@ -46671,13 +46671,13 @@ "type": "object" }, "PathognomonicityQuantifier": { - "additionalProperties": true, + "additionalProperties": false, "description": "A relationship quantifier between a variant or symptom and a disease, which is high when the presence of the feature implies the existence of the disease", "title": "PathognomonicityQuantifier", "type": "object" }, "PathologicalAnatomicalExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "An abnormal anatomical structure, when viewed as an exposure, representing an precondition, leading to or influencing an outcome, e.g. thrombosis leading to an ischemic disease outcome.", "properties": { "category": { @@ -46818,13 +46818,13 @@ "type": "object" }, "PathologicalAnatomicalOutcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "An outcome resulting from an exposure event which is the manifestation of an abnormal anatomical structure.", "title": "PathologicalAnatomicalOutcome", "type": "object" }, "PathologicalAnatomicalStructure": { - "additionalProperties": true, + "additionalProperties": false, "description": "An anatomical structure with the potential of have an abnormal or deleterious effect at the subcellular, cellular, multicellular, or organismal level.", "properties": { "category": { @@ -46952,13 +46952,13 @@ "type": "object" }, "PathologicalEntityMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "A pathological (abnormal) structure or process.", "title": "PathologicalEntityMixin", "type": "object" }, "PathologicalProcess": { - "additionalProperties": true, + "additionalProperties": false, "description": "A biologic function or a process having an abnormal or deleterious effect at the subcellular, cellular, multicellular, or organismal level.", "properties": { "category": { @@ -47116,7 +47116,7 @@ "type": "object" }, "PathologicalProcessExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A pathological process, when viewed as an exposure, representing a precondition, leading to or influencing an outcome, e.g. autoimmunity leading to disease.", "properties": { "category": { @@ -47257,13 +47257,13 @@ "type": "object" }, "PathologicalProcessOutcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "An outcome resulting from an exposure event which is the manifestation of a pathological process.", "title": "PathologicalProcessOutcome", "type": "object" }, "Pathway": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -47431,7 +47431,7 @@ "type": "string" }, "Phenomenon": { - "additionalProperties": true, + "additionalProperties": false, "description": "a fact or situation that is observed to exist or happen, especially one whose cause or explanation is in question", "properties": { "category": { @@ -47542,7 +47542,7 @@ "type": "object" }, "PhenotypicFeature": { - "additionalProperties": true, + "additionalProperties": false, "description": "A combination of entity and quality that makes up a phenotyping statement. An observable characteristic of an individual resulting from the interaction of its genotype with its molecular and physical environment.", "examples": [ "MP:0001262" @@ -47673,7 +47673,7 @@ "type": "object" }, "PhenotypicFeatureToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -48183,7 +48183,7 @@ "type": "object" }, "PhenotypicFeatureToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "frequency_qualifier": { @@ -48309,7 +48309,7 @@ "type": "object" }, "PhenotypicFeatureToPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Association between two concept nodes of phenotypic character, qualified by the predicate used. This association may typically be used to specify 'similar_to' or 'member_of' relationships.", "properties": { "adjusted_p_value": { @@ -48822,7 +48822,7 @@ "type": "object" }, "PhenotypicQuality": { - "additionalProperties": true, + "additionalProperties": false, "description": "A property of a phenotype", "examples": [ "weight" @@ -48958,7 +48958,7 @@ "type": "object" }, "PhenotypicSex": { - "additionalProperties": true, + "additionalProperties": false, "description": "An attribute corresponding to the phenotypic sex of the individual, based upon the reproductive organs present.", "properties": { "category": { @@ -49091,7 +49091,7 @@ "type": "object" }, "PhysicalEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "An entity that has material reality (a.k.a. physical essence).", "properties": { "category": { @@ -49202,19 +49202,19 @@ "type": "object" }, "PhysicalEssence": { - "additionalProperties": true, + "additionalProperties": false, "description": "Semantic mixin concept. Pertains to entities that have physical properties such as mass, volume, or charge.", "title": "PhysicalEssence", "type": "object" }, "PhysicalEssenceOrOccurrent": { - "additionalProperties": true, + "additionalProperties": false, "description": "Either a physical or processual entity.", "title": "PhysicalEssenceOrOccurrent", "type": "object" }, "PhysiologicalProcess": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -49372,7 +49372,7 @@ "type": "object" }, "PlanetaryEntity": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any entity or process that exists at the level of the whole planet", "properties": { "category": { @@ -49483,7 +49483,7 @@ "type": "object" }, "Plant": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -49611,7 +49611,7 @@ "type": "object" }, "Polypeptide": { - "additionalProperties": true, + "additionalProperties": false, "description": "A polypeptide is a molecular entity characterized by availability in protein databases of amino-acid-based sequence representations of its precise primary structure; for convenience of representation, partial sequences of various kinds are included, even if they do not represent a physical molecule.", "properties": { "category": { @@ -49739,7 +49739,7 @@ "type": "object" }, "PopulationOfIndividualOrganisms": { - "additionalProperties": true, + "additionalProperties": false, "description": "A collection of individuals from the same taxonomic class distinguished by one or more characteristics. Characteristics can include, but are not limited to, shared geographic location, genetics, phenotypes.", "properties": { "category": { @@ -49867,7 +49867,7 @@ "type": "object" }, "PopulationToPopulationAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a two populations", "properties": { "adjusted_p_value": { @@ -50238,7 +50238,7 @@ "type": "object" }, "PosttranslationalModification": { - "additionalProperties": true, + "additionalProperties": false, "description": "A chemical modification of a polypeptide or protein that occurs after translation. e.g. polypeptide cleavage to form separate proteins, methylation or acetylation of histone tail amino acids, protein ubiquitination.", "properties": { "category": { @@ -50366,7 +50366,7 @@ "type": "object" }, "PredicateMapping": { - "additionalProperties": true, + "additionalProperties": false, "description": "A deprecated predicate mapping object contains the deprecated predicate and an example of the rewiring that should be done to use a qualified statement in its place.", "properties": { "anatomical_context_qualifier": { @@ -50580,7 +50580,7 @@ "type": "object" }, "PreprintPublication": { - "additionalProperties": true, + "additionalProperties": false, "description": "a document reresenting an early version of an author's original scholarly work, such as a research paper or a review, prior to formal peer review and publication in a peer-reviewed scholarly or scientific journal.", "properties": { "authors": { @@ -50772,7 +50772,7 @@ "type": "object" }, "Procedure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A series of actions conducted in a certain order or manner", "properties": { "category": { @@ -50883,7 +50883,7 @@ "type": "object" }, "ProcessRegulatesProcessAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "Describes a regulatory relationship between two genes or gene products.", "properties": { "adjusted_p_value": { @@ -51257,7 +51257,7 @@ "type": "object" }, "ProcessedMaterial": { - "additionalProperties": true, + "additionalProperties": false, "description": "A chemical entity (often a mixture) processed for consumption for nutritional, medical or technical use. Is a material entity that is created or changed during material processing.", "properties": { "available_from": { @@ -51448,7 +51448,7 @@ "type": "object" }, "Protein": { - "additionalProperties": true, + "additionalProperties": false, "description": "A gene product that is composed of a chain of amino acid sequences and is produced by ribosome-mediated translation of mRNA", "properties": { "category": { @@ -51576,7 +51576,7 @@ "type": "object" }, "ProteinDomain": { - "additionalProperties": true, + "additionalProperties": false, "description": "A conserved part of protein sequence and (tertiary) structure that can evolve, function, and exist independently of the rest of the protein chain. Protein domains maintain their structure and function independently of the proteins in which they are found. e.g. an SH3 domain.", "properties": { "category": { @@ -51714,7 +51714,7 @@ "type": "object" }, "ProteinFamily": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -51852,7 +51852,7 @@ "type": "object" }, "ProteinIsoform": { - "additionalProperties": true, + "additionalProperties": false, "description": "Represents a protein that is a specific isoform of the canonical or reference protein. See https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4114032/", "properties": { "category": { @@ -51980,7 +51980,7 @@ "type": "object" }, "Publication": { - "additionalProperties": true, + "additionalProperties": false, "description": "Any \u2018published\u2019 piece of information. Publications are considered broadly to include any document or document part made available in print or on the web - which may include scientific journal issues, individual articles, and books - as well as things like pre-prints, white papers, patents, drug labels, web pages, protocol documents, and even a part of a publication if of significant knowledge scope (e.g. a figure, figure legend, or section highlighted by NLP).", "properties": { "authors": { @@ -52172,7 +52172,7 @@ "type": "object" }, "QuantityValue": { - "additionalProperties": true, + "additionalProperties": false, "description": "A value of an attribute that is quantitative and measurable, expressed as a combination of a unit and a numeric value", "properties": { "has_numeric_value": { @@ -52194,7 +52194,7 @@ "type": "object" }, "RNAProduct": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -52322,7 +52322,7 @@ "type": "object" }, "RNAProductIsoform": { - "additionalProperties": true, + "additionalProperties": false, "description": "Represents a protein that is a specific isoform of the canonical or reference RNA", "properties": { "category": { @@ -52470,7 +52470,7 @@ "type": "string" }, "ReactionToCatalystAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -52870,7 +52870,7 @@ "type": "object" }, "ReactionToParticipantAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -53270,7 +53270,7 @@ "type": "object" }, "ReagentTargetedGene": { - "additionalProperties": true, + "additionalProperties": false, "description": "A gene altered in its expression level in the context of some experiment as a result of being targeted by gene-knockdown reagent(s) such as a morpholino or RNAi.", "properties": { "category": { @@ -53405,7 +53405,7 @@ "type": "object" }, "RegulatoryRegion": { - "additionalProperties": true, + "additionalProperties": false, "description": "A region (or regions) of the genome that contains known or putative regulatory elements that act in cis- or trans- to affect the transcription of gene", "properties": { "category": { @@ -53540,13 +53540,13 @@ "type": "object" }, "RelationshipQuantifier": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "title": "RelationshipQuantifier", "type": "object" }, "RelationshipType": { - "additionalProperties": true, + "additionalProperties": false, "description": "An OWL property used as an edge label", "properties": { "id": { @@ -53561,7 +53561,7 @@ "type": "object" }, "RelativeFrequencyAnalysisResult": { - "additionalProperties": true, + "additionalProperties": false, "description": "A result of a relative frequency analysis.", "properties": { "category": { @@ -53722,7 +53722,7 @@ "type": "string" }, "RetrievalSource": { - "additionalProperties": true, + "additionalProperties": false, "description": "Provides information about how a particular InformationResource served as a source from which knowledge expressed in an Edge, or data used to generate this knowledge, was retrieved.", "properties": { "category": { @@ -53876,13 +53876,13 @@ "type": "object" }, "SensitivityQuantifier": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "title": "SensitivityQuantifier", "type": "object" }, "SequenceAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a sequence feature and a nucleic acid entity it is localized to.", "properties": { "adjusted_p_value": { @@ -54262,7 +54262,7 @@ "type": "string" }, "SequenceFeatureRelationship": { - "additionalProperties": true, + "additionalProperties": false, "description": "For example, a particular exon is part of a particular transcript or gene", "properties": { "adjusted_p_value": { @@ -54633,7 +54633,7 @@ "type": "object" }, "SequenceVariant": { - "additionalProperties": true, + "additionalProperties": false, "description": "A sequence_variant is a non exact copy of a sequence_feature or genome exhibiting one or more sequence_alteration.", "properties": { "category": { @@ -54782,7 +54782,7 @@ "type": "object" }, "SequenceVariantModulatesTreatmentAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a sequence variant and a treatment or health intervention. The treatment object itself encompasses both the disease and the drug used.", "properties": { "adjusted_p_value": { @@ -55153,7 +55153,7 @@ "type": "object" }, "Serial": { - "additionalProperties": true, + "additionalProperties": false, "description": "This class may rarely be instantiated except if use cases of a given knowledge graph support its utility.", "properties": { "authors": { @@ -55367,7 +55367,7 @@ "type": "object" }, "SeverityValue": { - "additionalProperties": true, + "additionalProperties": false, "description": "describes the severity of a phenotypic feature or disease", "properties": { "category": { @@ -55500,7 +55500,7 @@ "type": "object" }, "SiRNA": { - "additionalProperties": true, + "additionalProperties": false, "description": "A small RNA molecule that is the product of a longer exogenous or endogenous dsRNA, which is either a bimolecular duplex or very long hairpin, processed (via the Dicer pathway) such that numerous siRNAs accumulate from both strands of the dsRNA. SRNAs trigger the cleavage of their target molecules.", "properties": { "category": { @@ -55628,7 +55628,7 @@ "type": "object" }, "SmallMolecule": { - "additionalProperties": true, + "additionalProperties": false, "description": "A small molecule entity is a molecular entity characterized by availability in small-molecule databases of SMILES, InChI, IUPAC, or other unambiguous representation of its precise chemical structure; for convenience of representation, any valid chemical representation is included, even if it is not strictly molecular (e.g., sodium ion).", "properties": { "available_from": { @@ -55787,7 +55787,7 @@ "type": "object" }, "Snv": { - "additionalProperties": true, + "additionalProperties": false, "description": "SNVs are single nucleotide positions in genomic DNA at which different sequence alternatives exist", "properties": { "category": { @@ -55936,7 +55936,7 @@ "type": "object" }, "SocioeconomicAttribute": { - "additionalProperties": true, + "additionalProperties": false, "description": "Attributes relating to a socioeconomic manifestation", "properties": { "category": { @@ -56069,7 +56069,7 @@ "type": "object" }, "SocioeconomicExposure": { - "additionalProperties": true, + "additionalProperties": false, "description": "A socioeconomic exposure is a factor relating to social and financial status of an affected individual (e.g. poverty).", "properties": { "category": { @@ -56208,13 +56208,13 @@ "type": "object" }, "SocioeconomicOutcome": { - "additionalProperties": true, + "additionalProperties": false, "description": "An general social or economic outcome, such as healthcare costs, utilization, etc., resulting from an exposure event", "title": "SocioeconomicOutcome", "type": "object" }, "SpecificityQuantifier": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "title": "SpecificityQuantifier", "type": "object" @@ -56231,7 +56231,7 @@ "type": "string" }, "Study": { - "additionalProperties": true, + "additionalProperties": false, "description": "a detailed investigation and/or analysis", "properties": { "category": { @@ -56342,7 +56342,7 @@ "type": "object" }, "StudyPopulation": { - "additionalProperties": true, + "additionalProperties": false, "description": "A group of people banded together or treated as a group as participants in a research study.", "properties": { "category": { @@ -56470,7 +56470,7 @@ "type": "object" }, "StudyResult": { - "additionalProperties": true, + "additionalProperties": false, "description": "A collection of data items from a study that are about a particular study subject or experimental unit (the 'focus' of the Result) - optionally with context/provenance metadata that may be relevant to the interpretation of this data as evidence.", "properties": { "category": { @@ -56607,7 +56607,7 @@ "type": "object" }, "StudyVariable": { - "additionalProperties": true, + "additionalProperties": false, "description": "a variable that is used as a measure in the investigation of a study", "properties": { "category": { @@ -56744,13 +56744,13 @@ "type": "object" }, "SubjectOfInvestigation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An entity that has the role of being studied in an investigation, study, or experiment", "title": "SubjectOfInvestigation", "type": "object" }, "TaxonToTaxonAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -57121,7 +57121,7 @@ "type": "object" }, "TaxonomicRank": { - "additionalProperties": true, + "additionalProperties": false, "description": "A descriptor for the rank within a taxonomic classification. Example instance: TAXRANK:0000017 (kingdom)", "properties": { "id": { @@ -57136,7 +57136,7 @@ "type": "object" }, "TextMiningResult": { - "additionalProperties": true, + "additionalProperties": false, "description": "A result of text mining.", "properties": { "category": { @@ -57273,7 +57273,7 @@ "type": "object" }, "ThingWithTaxon": { - "additionalProperties": true, + "additionalProperties": false, "description": "A mixin that can be used on any entity that can be taxonomically classified. This includes individual organisms; genes, their products and other molecular entities; body parts; biological processes", "properties": { "in_taxon": { @@ -57298,7 +57298,7 @@ "type": "object" }, "Transcript": { - "additionalProperties": true, + "additionalProperties": false, "description": "An RNA synthesized on a DNA or RNA template by an RNA polymerase.", "properties": { "category": { @@ -57426,7 +57426,7 @@ "type": "object" }, "TranscriptToGeneRelationship": { - "additionalProperties": true, + "additionalProperties": false, "description": "A gene is a collection of transcripts", "properties": { "adjusted_p_value": { @@ -57797,7 +57797,7 @@ "type": "object" }, "TranscriptionFactorBindingSite": { - "additionalProperties": true, + "additionalProperties": false, "description": "A region (or regions) of the genome that contains a region of DNA known or predicted to bind a protein that modulates gene transcription", "properties": { "category": { @@ -57932,7 +57932,7 @@ "type": "object" }, "Treatment": { - "additionalProperties": true, + "additionalProperties": false, "description": "A treatment is targeted at a disease or phenotype and may involve multiple drug 'exposures', medical devices and/or procedures", "properties": { "category": { @@ -58081,7 +58081,7 @@ "type": "object" }, "VariantAsAModelOfDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -58534,7 +58534,7 @@ "type": "object" }, "VariantToDiseaseAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -58987,7 +58987,7 @@ "type": "object" }, "VariantToEntityAssociationMixin": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "object": { @@ -59016,7 +59016,7 @@ "type": "object" }, "VariantToGeneAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a variant and a gene, where the variant has a genetic association with the gene (i.e. is in linkage disequilibrium)", "properties": { "adjusted_p_value": { @@ -59396,7 +59396,7 @@ "type": "object" }, "VariantToGeneExpressionAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a variant and expression of a gene (i.e. e-QTL)", "properties": { "adjusted_p_value": { @@ -59817,7 +59817,7 @@ "type": "object" }, "VariantToPhenotypicFeatureAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "adjusted_p_value": { @@ -60329,7 +60329,7 @@ "type": "object" }, "VariantToPopulationAssociation": { - "additionalProperties": true, + "additionalProperties": false, "description": "An association between a variant and a population, where the variant has particular frequency in the population", "properties": { "adjusted_p_value": { @@ -60750,7 +60750,7 @@ "type": "object" }, "Vertebrate": { - "additionalProperties": true, + "additionalProperties": false, "description": "A sub-phylum of animals consisting of those having a bony or cartilaginous vertebral column.", "properties": { "category": { @@ -60878,7 +60878,7 @@ "type": "object" }, "Virus": { - "additionalProperties": true, + "additionalProperties": false, "description": "A virus is a microorganism that replicates itself as a microRNA and infects the host cell.", "properties": { "category": { @@ -61006,7 +61006,7 @@ "type": "object" }, "WebPage": { - "additionalProperties": true, + "additionalProperties": false, "description": "a document that is published according to World Wide Web standards, which may incorporate text, graphics, sound, and/or other features.", "properties": { "authors": { @@ -61198,7 +61198,7 @@ "type": "object" }, "Zygosity": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "category": { @@ -61333,7 +61333,7 @@ }, "$id": "https://w3id.org/biolink/biolink-model", "$schema": "https://json-schema.org/draft/2019-09/schema", - "additionalProperties": true, + "additionalProperties": false, "description": "A collection of deprecated mappings.", "metamodel_version": "1.11.0", "properties": { diff --git a/tests/linkml/test_generators/test_jsonschemagen.py b/tests/linkml/test_generators/test_jsonschemagen.py index 058042ec94..b22dcc7ae3 100644 --- a/tests/linkml/test_generators/test_jsonschemagen.py +++ b/tests/linkml/test_generators/test_jsonschemagen.py @@ -1672,3 +1672,116 @@ def test_generate_array_error_complex_unbounded_shape(array_error_complex_unboun _ = JsonSchemaGenerator( array_error_complex_unbounded, ).generate() + + +_EXTRA_SLOTS_SCHEMA = """ +id: https://example.org/extra-slots-default +name: extra_slots_default +prefixes: + linkml: https://w3id.org/linkml/ +default_range: string +imports: + - linkml:types +classes: + Closed: + slots: + - name + ExplicitlyOpen: + extra_slots: + allowed: true + slots: + - name +slots: + name: + range: string +""" + + +def _generate(tmp_path, schema_text, **kwargs): + schema_path = tmp_path / "schema.yaml" + schema_path.write_text(schema_text) + return json.loads(JsonSchemaGenerator(str(schema_path), **kwargs).serialize()) + + +@pytest.mark.jsonschemagen +@pytest.mark.parametrize( + "kwargs,expected", + [ + pytest.param({}, False, id="default-is-closed"), + pytest.param({"not_closed": False}, False, id="closed"), + pytest.param({"not_closed": True}, True, id="not-closed"), + ], +) +def test_extra_slots_absent_defaults_to_closed(tmp_path, kwargs, expected): + """A class with no ``extra_slots`` is closed unless ``not_closed`` says otherwise. + + ``meta.yaml`` documents an absent ``extra_slots`` as "forbid all additional data + (default)", so the generator must not silently open such classes. + """ + schema = _generate(tmp_path, _EXTRA_SLOTS_SCHEMA, **kwargs) + + assert schema["$defs"]["Closed"]["additionalProperties"] is expected + # An explicit `extra_slots.allowed` always wins, whatever `not_closed` says. + assert schema["$defs"]["ExplicitlyOpen"]["additionalProperties"] is True + + +@pytest.mark.jsonschemagen +@pytest.mark.parametrize("kwargs", [{}, {"not_closed": False}, {"not_closed": True}]) +def test_rootless_schema_keeps_an_open_top_level(tmp_path, kwargs): + """With no root class the top level has no properties, so it must stay open. + + Closing it would produce a schema admitting nothing but ``{}``. ``not_closed`` + governs classes, not the top level. + """ + schema = _generate(tmp_path, _EXTRA_SLOTS_SCHEMA, **kwargs) + + assert schema["additionalProperties"] is True + jsonschema.validate({"name": "alice"}, schema) + + +@pytest.mark.jsonschemagen +@pytest.mark.parametrize("root_via", ["tree_root", "top_class"]) +def test_root_class_governs_the_top_level(tmp_path, root_via): + """The root class sets the top-level ``additionalProperties``. + + Previously only ``--top-class`` did this and ``tree_root: true`` did not, so the + two disagreed about the same document (linkml#3608). + """ + schema_text = _EXTRA_SLOTS_SCHEMA + kwargs = {} + if root_via == "tree_root": + schema_text = schema_text.replace(" Closed:\n", " Closed:\n tree_root: true\n") + else: + kwargs["top_class"] = "Closed" + + schema = _generate(tmp_path, schema_text, **kwargs) + + assert schema["additionalProperties"] is False + assert schema["$defs"]["Closed"]["additionalProperties"] is False + + +@pytest.mark.jsonschemagen +def test_multiple_tree_roots_pick_one_consistently(tmp_path): + """With more than one ``tree_root``, the top level comes from a single class. + + biolink-model declares two. The top-level ``additionalProperties`` and the + subschema merged beneath it must not come from different classes. + """ + schema_text = _EXTRA_SLOTS_SCHEMA.replace(" Closed:\n", " Closed:\n tree_root: true\n").replace( + " ExplicitlyOpen:\n", " ExplicitlyOpen:\n tree_root: true\n" + ) + schema = _generate(tmp_path, schema_text) + + # `Closed` is first, so it is the root: closed, and its properties are merged up. + assert schema["additionalProperties"] is False + assert set(schema["properties"]) == set(schema["$defs"]["Closed"]["properties"]) + + +@pytest.mark.jsonschemagen +def test_top_class_matches_regardless_of_case(tmp_path): + """``top_class`` is habitually passed in CamelCase for a spelled-out class name.""" + schema_text = _EXTRA_SLOTS_SCHEMA.replace(" Closed:\n", " closed thing:\n") + schema = _generate(tmp_path, schema_text, top_class="ClosedThing") + + assert schema["additionalProperties"] is False + assert "name" in schema["properties"] diff --git a/tests/linkml/test_issues/__snapshots__/issue_120.json b/tests/linkml/test_issues/__snapshots__/issue_120.json index 5f5c4d5f52..a0780fec2f 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_120.json +++ b/tests/linkml/test_issues/__snapshots__/issue_120.json @@ -1,7 +1,7 @@ { "$defs": { "Course": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "name": { @@ -12,7 +12,7 @@ "type": "object" }, "Student": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "courses": { diff --git a/tests/linkml/test_issues/__snapshots__/issue_177.json b/tests/linkml/test_issues/__snapshots__/issue_177.json index d520ab4c1d..70cc2303ee 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_177.json +++ b/tests/linkml/test_issues/__snapshots__/issue_177.json @@ -1,7 +1,7 @@ { "$defs": { "C1": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "sa": { @@ -21,7 +21,7 @@ "type": "object" }, "C2": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "sb": { @@ -37,7 +37,7 @@ }, "$id": "http://example.org/tests/issue177", "$schema": "https://json-schema.org/draft/2019-09/schema", - "additionalProperties": true, + "additionalProperties": false, "description": "", "metamodel_version": "1.11.0", "properties": { diff --git a/tests/linkml/test_issues/__snapshots__/issue_202.json.schema b/tests/linkml/test_issues/__snapshots__/issue_202.json.schema index 50644c1f8b..3ad99309c8 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_202.json.schema +++ b/tests/linkml/test_issues/__snapshots__/issue_202.json.schema @@ -1,7 +1,7 @@ { "$defs": { "GeospatialDDCoordLocation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "latitude": { diff --git a/tests/linkml/test_issues/__snapshots__/issue_239.json b/tests/linkml/test_issues/__snapshots__/issue_239.json index 81ee092116..006ef21a37 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_239.json +++ b/tests/linkml/test_issues/__snapshots__/issue_239.json @@ -1,7 +1,7 @@ { "$defs": { "C": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": {}, diff --git a/tests/linkml/test_issues/__snapshots__/issue_2499.json b/tests/linkml/test_issues/__snapshots__/issue_2499.json index 14afe9de30..196c4d7b54 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_2499.json +++ b/tests/linkml/test_issues/__snapshots__/issue_2499.json @@ -1,7 +1,7 @@ { "$defs": { "Thing": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "identifier": { @@ -18,7 +18,7 @@ }, "$id": "http://example.com/dataset", "$schema": "https://json-schema.org/draft/2019-09/schema", - "additionalProperties": true, + "additionalProperties": false, "description": "", "metamodel_version": "1.11.0", "properties": { diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonschema/meta.json b/tests/linkml/test_scripts/__snapshots__/genjsonschema/meta.json index 7879b587a7..9bf8ffdfee 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonschema/meta.json +++ b/tests/linkml/test_scripts/__snapshots__/genjsonschema/meta.json @@ -1,7 +1,7 @@ { "$defs": { "Activity": { - "additionalProperties": true, + "additionalProperties": false, "description": "a provence-generating activity", "properties": { "description": { @@ -53,7 +53,7 @@ "type": "object" }, "Address": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "altitude": { @@ -79,7 +79,7 @@ "type": "object" }, "Agent": { - "additionalProperties": true, + "additionalProperties": false, "description": "a provence-generating agent", "properties": { "acted_on_behalf_of": { @@ -117,7 +117,7 @@ ] }, "AnyOfClasses": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute2": { @@ -135,7 +135,7 @@ "type": "object" }, "AnyOfEnums": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute3": { @@ -156,7 +156,7 @@ "type": "object" }, "AnyOfMix": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute4": { @@ -180,7 +180,7 @@ "type": "object" }, "AnyOfSimpleType": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute1": { @@ -201,7 +201,7 @@ "type": "object" }, "BirthEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "ended_at_time": { @@ -246,7 +246,7 @@ "type": "object" }, "ClassWithSpaces": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "slot_with_space_1": { @@ -260,7 +260,7 @@ "type": "object" }, "CodeSystem": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": { @@ -290,7 +290,7 @@ "type": "object" }, "Company": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "aliases": { @@ -325,7 +325,7 @@ "type": "object" }, "Concept": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": { @@ -361,7 +361,7 @@ "type": "string" }, "Dataset": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "activities": { @@ -416,7 +416,7 @@ "type": "object" }, "DiagnosisConcept": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": { @@ -450,7 +450,7 @@ "type": "string" }, "EmploymentEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "employed_at": { @@ -519,7 +519,7 @@ "type": "string" }, "EqualsString": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute5": { @@ -534,7 +534,7 @@ "type": "object" }, "EqualsStringIn": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute6": { @@ -552,7 +552,7 @@ "type": "object" }, "Event": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "ended_at_time": { @@ -591,7 +591,7 @@ "type": "object" }, "FakeClass": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "test_attribute": { @@ -605,7 +605,7 @@ "type": "object" }, "FamilialRelationship": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "cordialness": { @@ -657,7 +657,7 @@ "type": "string" }, "Friend": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "name": { @@ -671,7 +671,7 @@ "type": "object" }, "HasAliases": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "aliases": { @@ -731,7 +731,7 @@ "type": "string" }, "MarriageEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "ended_at_time": { @@ -782,7 +782,7 @@ "type": "object" }, "MedicalEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "diagnosis": { @@ -847,7 +847,7 @@ "type": "object" }, "Organization": { - "additionalProperties": true, + "additionalProperties": false, "description": "An organization.\n\nThis description\nincludes newlines\n\n## Markdown headers\n\n * and\n * a\n * list", "properties": { "aliases": { @@ -884,7 +884,7 @@ "type": "string" }, "Person": { - "additionalProperties": true, + "additionalProperties": false, "description": "A person, living or dead", "properties": { "addresses": { @@ -994,7 +994,7 @@ "type": "object" }, "Place": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "aliases": { @@ -1023,7 +1023,7 @@ "type": "object" }, "ProcedureConcept": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": { @@ -1049,7 +1049,7 @@ "type": "object" }, "Relationship": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "cordialness": { @@ -1093,7 +1093,7 @@ "type": "object" }, "SubSubClass2": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "slot_with_space_1": { @@ -1117,7 +1117,7 @@ "type": "object" }, "SubclassTest": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "slot_with_space_1": { @@ -1141,7 +1141,7 @@ "type": "object" }, "TubSubClass1": { - "additionalProperties": true, + "additionalProperties": false, "description": "Same depth as Sub sub class 1", "properties": { "slot_with_space_1": { @@ -1165,7 +1165,7 @@ "type": "object" }, "WithLocation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "in_location": { @@ -1181,7 +1181,7 @@ }, "$id": "https://w3id.org/linkml/tests/kitchen_sink", "$schema": "https://json-schema.org/draft/2019-09/schema", - "additionalProperties": true, + "additionalProperties": false, "description": "", "metamodel_version": "1.11.0", "properties": { diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonschema/meta_inline.json b/tests/linkml/test_scripts/__snapshots__/genjsonschema/meta_inline.json index 7879b587a7..9bf8ffdfee 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonschema/meta_inline.json +++ b/tests/linkml/test_scripts/__snapshots__/genjsonschema/meta_inline.json @@ -1,7 +1,7 @@ { "$defs": { "Activity": { - "additionalProperties": true, + "additionalProperties": false, "description": "a provence-generating activity", "properties": { "description": { @@ -53,7 +53,7 @@ "type": "object" }, "Address": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "altitude": { @@ -79,7 +79,7 @@ "type": "object" }, "Agent": { - "additionalProperties": true, + "additionalProperties": false, "description": "a provence-generating agent", "properties": { "acted_on_behalf_of": { @@ -117,7 +117,7 @@ ] }, "AnyOfClasses": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute2": { @@ -135,7 +135,7 @@ "type": "object" }, "AnyOfEnums": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute3": { @@ -156,7 +156,7 @@ "type": "object" }, "AnyOfMix": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute4": { @@ -180,7 +180,7 @@ "type": "object" }, "AnyOfSimpleType": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute1": { @@ -201,7 +201,7 @@ "type": "object" }, "BirthEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "ended_at_time": { @@ -246,7 +246,7 @@ "type": "object" }, "ClassWithSpaces": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "slot_with_space_1": { @@ -260,7 +260,7 @@ "type": "object" }, "CodeSystem": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": { @@ -290,7 +290,7 @@ "type": "object" }, "Company": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "aliases": { @@ -325,7 +325,7 @@ "type": "object" }, "Concept": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": { @@ -361,7 +361,7 @@ "type": "string" }, "Dataset": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "activities": { @@ -416,7 +416,7 @@ "type": "object" }, "DiagnosisConcept": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": { @@ -450,7 +450,7 @@ "type": "string" }, "EmploymentEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "employed_at": { @@ -519,7 +519,7 @@ "type": "string" }, "EqualsString": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute5": { @@ -534,7 +534,7 @@ "type": "object" }, "EqualsStringIn": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "attribute6": { @@ -552,7 +552,7 @@ "type": "object" }, "Event": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "ended_at_time": { @@ -591,7 +591,7 @@ "type": "object" }, "FakeClass": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "test_attribute": { @@ -605,7 +605,7 @@ "type": "object" }, "FamilialRelationship": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "cordialness": { @@ -657,7 +657,7 @@ "type": "string" }, "Friend": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "name": { @@ -671,7 +671,7 @@ "type": "object" }, "HasAliases": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "aliases": { @@ -731,7 +731,7 @@ "type": "string" }, "MarriageEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "ended_at_time": { @@ -782,7 +782,7 @@ "type": "object" }, "MedicalEvent": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "diagnosis": { @@ -847,7 +847,7 @@ "type": "object" }, "Organization": { - "additionalProperties": true, + "additionalProperties": false, "description": "An organization.\n\nThis description\nincludes newlines\n\n## Markdown headers\n\n * and\n * a\n * list", "properties": { "aliases": { @@ -884,7 +884,7 @@ "type": "string" }, "Person": { - "additionalProperties": true, + "additionalProperties": false, "description": "A person, living or dead", "properties": { "addresses": { @@ -994,7 +994,7 @@ "type": "object" }, "Place": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "aliases": { @@ -1023,7 +1023,7 @@ "type": "object" }, "ProcedureConcept": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "id": { @@ -1049,7 +1049,7 @@ "type": "object" }, "Relationship": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "cordialness": { @@ -1093,7 +1093,7 @@ "type": "object" }, "SubSubClass2": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "slot_with_space_1": { @@ -1117,7 +1117,7 @@ "type": "object" }, "SubclassTest": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "slot_with_space_1": { @@ -1141,7 +1141,7 @@ "type": "object" }, "TubSubClass1": { - "additionalProperties": true, + "additionalProperties": false, "description": "Same depth as Sub sub class 1", "properties": { "slot_with_space_1": { @@ -1165,7 +1165,7 @@ "type": "object" }, "WithLocation": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "properties": { "in_location": { @@ -1181,7 +1181,7 @@ }, "$id": "https://w3id.org/linkml/tests/kitchen_sink", "$schema": "https://json-schema.org/draft/2019-09/schema", - "additionalProperties": true, + "additionalProperties": false, "description": "", "metamodel_version": "1.11.0", "properties": { diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonschema/roottest.json b/tests/linkml/test_scripts/__snapshots__/genjsonschema/roottest.json index 72d4e72e39..76b928cea0 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonschema/roottest.json +++ b/tests/linkml/test_scripts/__snapshots__/genjsonschema/roottest.json @@ -1,13 +1,13 @@ { "$defs": { "C1": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "title": "C1", "type": "object" }, "C2": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "title": "C2", "type": "object" @@ -15,7 +15,7 @@ }, "$id": "http://example.org/tests/issue177", "$schema": "https://json-schema.org/draft/2019-09/schema", - "additionalProperties": true, + "additionalProperties": false, "description": "", "metamodel_version": "1.11.0", "title": "issue177", diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonschema/roottest2.json b/tests/linkml/test_scripts/__snapshots__/genjsonschema/roottest2.json index 72d4e72e39..76b928cea0 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonschema/roottest2.json +++ b/tests/linkml/test_scripts/__snapshots__/genjsonschema/roottest2.json @@ -1,13 +1,13 @@ { "$defs": { "C1": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "title": "C1", "type": "object" }, "C2": { - "additionalProperties": true, + "additionalProperties": false, "description": "", "title": "C2", "type": "object" @@ -15,7 +15,7 @@ }, "$id": "http://example.org/tests/issue177", "$schema": "https://json-schema.org/draft/2019-09/schema", - "additionalProperties": true, + "additionalProperties": false, "description": "", "metamodel_version": "1.11.0", "title": "issue177", From 3864e087f1e2777206057dcf86082b745b9786d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:46:54 +0000 Subject: [PATCH 14/24] Update metamodel test fixtures from linkml-model --- .../test_metamodel_compat/input/metamodel/meta.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/linkml/test_metamodel_compat/input/metamodel/meta.yaml b/tests/linkml/test_metamodel_compat/input/metamodel/meta.yaml index 63dadbfd74..cbe98b801a 100644 --- a/tests/linkml/test_metamodel_compat/input/metamodel/meta.yaml +++ b/tests/linkml/test_metamodel_compat/input/metamodel/meta.yaml @@ -436,11 +436,13 @@ slots: aliases: - workflow status domain: element - range: uriorcurie + range: string description: status of the element - slot_uri: bibo:status + close_mappings: + - bibo:status examples: - - value: "bibo:draft" + - value: "testing" + - value: "unstable" see_also: - https://www.hl7.org/fhir/valueset-publication-status.html ## Draft, Active, Retired, Unknown - https://www.hl7.org/fhir/versions.html#std-process ## Draft, Trial Use, Normative, Informative, Deprecated From 5ef7622e727470cfad637e7519240d275df836c3 Mon Sep 17 00:00:00 2001 From: Silvano Cirujano Cuesta Date: Fri, 25 Sep 2026 19:11:50 +0200 Subject: [PATCH 15/24] Warn hybrid schemaloader-schemaview use --- .../src/linkml/generators/jsonschemagen.py | 2 - .../linkml/src/linkml/generators/owlgen.py | 2 +- packages/linkml/src/linkml/utils/generator.py | 98 ++++++++++++++----- .../linkml/test_generators/test_linkmlgen.py | 22 +++++ tests/linkml/test_utils/test_generator.py | 84 ++++++++++++++++ 5 files changed, 178 insertions(+), 30 deletions(-) diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 1da7a24539..586b21dc4b 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -483,8 +483,6 @@ def __post_init__(self): self.top_class = self.topClass super().__post_init__() - if self.namespaces is None: - raise TypeError("Schema text must be supplied to JSON schema generator. Preparsed schema will not work") # Set the class variable for JsonSchema to use JsonSchema.PRESERVE_NAMES = self.preserve_names diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 50be266df0..7ba15df672 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -1556,7 +1556,7 @@ def _range_uri(self, slot: SlotDefinition) -> URIRef: if self.type_objects: return self._type_uri(typ.name) else: - return self.namespaces.uri_for(typ.uri) + return URIRef(self.schemaview.get_uri(typ, expand=True)) elif slot.range in self.schema.enums: # TODO: enums fill this in return self._enum_uri(EnumDefinitionName(slot.range)) diff --git a/packages/linkml/src/linkml/utils/generator.py b/packages/linkml/src/linkml/utils/generator.py index 6d5df36eb8..dc338941e4 100644 --- a/packages/linkml/src/linkml/utils/generator.py +++ b/packages/linkml/src/linkml/utils/generator.py @@ -20,6 +20,7 @@ import os import re import sys +import warnings from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import dataclass, field @@ -30,7 +31,6 @@ import click import yaml from click import Argument, Command, Option -from jsonasobj2 import JsonObj from linkml import LOCAL_METAMODEL_YAML_FILE from linkml.cli.logging import DEFAULT_LOG_LEVEL_INT, log_level_option @@ -162,8 +162,67 @@ class Generator(metaclass=abc.ABCMeta): """Path to output file. Note all generators may not implement this uniformly, see https://github.com/linkml/linkml/issues/923""" - namespaces: Namespaces | None = None - """All prefix expansions used""" + _namespaces: ClassVar[Namespaces | None] = None + """Class-level sentinel default for the private backing store of the + :attr:`namespaces` property. Declaring it as a ``ClassVar`` keeps it out of + the dataclass-generated ``__init__`` while still providing a safe default + read (``None``) before the instance attribute is assigned. The public, + constructor-visible name is the field ``namespaces``. On ``main`` that was a + plain dataclass field, so ``namespaces=`` was an implicit constructor kwarg; + it is preserved here so external callers/subclasses relying on it are not + silently broken by the switch to a property-backed field. + """ + + @property + def namespaces(self) -> Namespaces | None: + """Return the namespace registry. + + On the SchemaLoader path (``uses_schemaloader=True``) this returns the + pre-built :class:`~linkml_runtime.utils.namespaces.Namespaces` object + populated by SchemaLoader. + + On the SchemaView path (``uses_schemaloader=False``) accessing this + property is a sign of a hybrid design anti-pattern. A deprecation + warning is emitted and the call is transparently forwarded to + ``self.schemaview.namespaces()`` so that existing callers continue to + work while being nudged towards the correct API. + """ + # Emit a warning when a SchemaView-based generator reads self.namespaces. + if not self.uses_schemaloader and self.schemaview is not None: + warnings.warn( + f"{type(self).__name__} uses SchemaView (uses_schemaloader=False) but " + "self.namespaces was accessed. Use self.schemaview.namespaces() for URI " + "resolution instead; self.namespaces is a SchemaLoader-era artifact that " + "is not populated on the SchemaView path.", + UserWarning, + stacklevel=3, + ) + + # Return the namespace map, preferring an explicitly injected one. + if self._namespaces is not None: + return self._namespaces + if not self.uses_schemaloader and self.schemaview is not None: + return self.schemaview.namespaces() + return None + + @namespaces.setter + def namespaces(self, value: Namespaces | None) -> None: + """Save passed namespace registry in the private backing store of the + :attr:`namespaces` property.""" + self._namespaces = value + + namespaces: Namespaces | None = namespaces + """Constructor kwarg backing the ``namespaces`` property (see above). + + This does NOT create a second attribute that shadows the property. + A dataclass "field" is just an *annotation* plus a *default value*; + the only real class attribute named ``namespaces`` remains the + ``namespaces`` property object. + ``@dataclass`` reads that property object as the field's default and bakes it + into the generated ``__init__`` as ``namespaces=`` + -- it does not overwrite the property, so attribute access still goes through + the getter/setter. + """ directory_output: bool = False """True means output is to a directory, False is to stdout""" @@ -191,6 +250,13 @@ class Generator(metaclass=abc.ABCMeta): """If set, include extra schema outside of the imports mechanism""" def __post_init__(self) -> None: + # The ``namespaces`` dataclass field defaults to the property object + # itself (see its declaration). When no ``namespaces=`` kwarg is passed, + # the generated __init__ routes that default through the property setter + # into ``self._namespaces``; normalise that sentinel back to ``None`` so + # the SchemaLoader/SchemaView paths can populate it as usual. + if self._namespaces is Generator.__dict__["namespaces"]: + self._namespaces = None if not self.logger: self.logger = logger if self.log_level is not None: @@ -241,8 +307,6 @@ def __post_init__(self) -> None: if not self.include_generation_date and self.schema is not None: self.schema.generation_date = None - self._init_namespaces() - def _initialize_using_schemaloader(self, schema: Union[str, TextIO, SchemaDefinition, "Generator"]): # currently generators are very liberal in what they accept, including # other generators. @@ -253,7 +317,7 @@ def _initialize_using_schemaloader(self, schema: Union[str, TextIO, SchemaDefini self.schema = gen.schema self.synopsis = gen.synopsis self.loaded = gen.loaded - self.namespaces = gen.namespaces + self._namespaces = gen.namespaces self.base_dir = gen.base_dir self.importmap = gen.importmap self.source_file_data = gen.source_file_date @@ -282,7 +346,7 @@ def _initialize_using_schemaloader(self, schema: Union[str, TextIO, SchemaDefini self.schema = loader.schema self.synopsis = loader.synopsis self.loaded = loader.loaded - self.namespaces = loader.namespaces + self._namespaces = loader.namespaces self.base_dir = loader.base_dir self.importmap = loader.importmap self.source_file_data = loader.source_file_date @@ -290,26 +354,6 @@ def _initialize_using_schemaloader(self, schema: Union[str, TextIO, SchemaDefini self.schema_location = loader.schema_location self.schema_defaults = loader.schema_defaults - def _init_namespaces(self): - if self.namespaces is None: - self.namespaces = Namespaces() - if isinstance(self.schema.prefixes, dict): - for key, value in self.schema.prefixes.items(): - if hasattr(value, "prefix_reference"): - self.namespaces[key] = value.prefix_reference - else: - self.namespaces[key] = value - elif isinstance(self.schema.prefixes, JsonObj): - prefixes = vars(self.schema.prefixes) - for key, value in prefixes.items(): - if hasattr(value, "prefix_reference"): - self.namespaces[key] = value.prefix_reference - else: - self.namespaces[key] = value - else: - for prefix in self.schema.prefixes.values(): - self.namespaces[prefix.prefix_prefix] = prefix.prefix_reference - @classmethod def validate_generator_args(cls, args: Mapping[str, Any]) -> None: """Validate ``generator_args`` before any generator is built from them. diff --git a/tests/linkml/test_generators/test_linkmlgen.py b/tests/linkml/test_generators/test_linkmlgen.py index 52529752f5..941688ce0d 100644 --- a/tests/linkml/test_generators/test_linkmlgen.py +++ b/tests/linkml/test_generators/test_linkmlgen.py @@ -1,3 +1,4 @@ +import pytest import yaml from click.testing import CliRunner @@ -27,6 +28,27 @@ def test_linkmlgen_prefixes(): assert "equipment_schema" in parsed["prefixes"] +def test_schemaview_generator_namespaces_access_warns(kitchen_sink_path): + """Accessing self.namespaces on a uses_schemaloader=False generator must + emit a UserWarning and still return a usable Namespaces object. + + self.namespaces is a SchemaLoader-era artifact. SchemaView-based generators + should use self.schemaview.namespaces() directly. The warning flags any + hybrid use so that it can be migrated to the correct API. + """ + gen = LinkmlGenerator(kitchen_sink_path, format="yaml") + assert not gen.uses_schemaloader + + with pytest.warns(UserWarning, match="self.namespaces.*SchemaLoader-era"): + ns = gen.namespaces + + # The returned value must still be a functional Namespaces object + # forwarded from self.schemaview.namespaces() so that existing callers + # continue to work while being migrated. + assert ns is not None + assert ns == gen.schemaview.namespaces() + + def test_generate(kitchen_sink_path): sv = SchemaView(kitchen_sink_path) assert "activity" in sv.all_classes(imports=True) diff --git a/tests/linkml/test_utils/test_generator.py b/tests/linkml/test_utils/test_generator.py index 48cd036029..94d96a1545 100644 --- a/tests/linkml/test_utils/test_generator.py +++ b/tests/linkml/test_utils/test_generator.py @@ -84,6 +84,11 @@ def visit_subset(self, subset: SubsetDefinition) -> None: self.visited.append(f"subset: {subset.name}") +@dataclass +class SchemaViewGeneratorTest(GeneratorTest): + uses_schemaloader = False + + # visit_all_class_slots = True, visits_are_sorted = False, sort_class_slots = False expected1 = [ "init", @@ -396,6 +401,85 @@ def test_default_prefix(): GeneratorTest(model + "\n\ndefault_prefix: CCCC") +def test_schema_view_prefix_namespaces(tmp_path): + """SchemaView prefix objects are unwrapped when namespaces are initialized.""" + schema_path = tmp_path / "schema.yaml" + schema_path.write_text( + """id: https://example.org/test +name: test +prefixes: + ex: https://example.org/test/ +default_prefix: ex +classes: + Foo: +""" + ) + + generator = SchemaViewGeneratorTest(schema_path) + + assert str(generator.namespaces["ex"]) == "https://example.org/test/" + + +def test_namespaces_constructor_kwarg(): + """The ``namespaces=`` constructor kwarg is accepted for backward compat. + + ``namespaces`` is exposed as a property backed by ``_namespaces``. Renaming + the backing field must not drop the public ``namespaces=`` kwarg that worked + before the SchemaLoader/SchemaView split. + + - On the SchemaLoader path the kwarg is accepted (no ``TypeError``) and the + map is (re)populated from the resolved schema, as on ``main``. + - On the SchemaView path an injected map is honored verbatim. + """ + from linkml_runtime.utils.namespaces import Namespaces + + model = """ +id: http://example.org/test/t1 +name: t1 +default_range: string +prefixes: + xsd: http://www.w3.org/2001/XMLSchema# +default_prefix: xsd +""" + + # SchemaLoader path: kwarg must be accepted (previously raised TypeError). + injected = Namespaces() + injected["ex"] = "http://example.org/injected/" + gen = GeneratorTest(model, namespaces=injected) + assert gen.namespaces is not None + assert "xsd" in gen.namespaces + + # Omitting the kwarg still yields a populated map on the SchemaLoader path. + gen_default = GeneratorTest(model) + assert gen_default.namespaces is not None + assert "xsd" in gen_default.namespaces + + +def test_namespaces_constructor_kwarg_injection_schemaview(tmp_path): + """On the SchemaView path an injected ``namespaces=`` map is honored.""" + from linkml_runtime.utils.namespaces import Namespaces + + schema_path = tmp_path / "schema.yaml" + schema_path.write_text( + """id: https://example.org/test +name: test +prefixes: + ex: https://example.org/test/ +default_prefix: ex +classes: + Foo: +""" + ) + + injected = Namespaces() + injected["custom"] = "http://example.org/custom/" + generator = SchemaViewGeneratorTest(schema_path, namespaces=injected) + + with pytest.warns(UserWarning, match="self.namespaces.*SchemaLoader-era"): + namespaces = generator.namespaces + assert namespaces["custom"] == "http://example.org/custom/" + + def test_duplicate_names(): """Test duplicate name for slot and type detection""" model = """ From d8035d3dd8e6c2c9cbd0556868612735110b68aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:44:51 -0500 Subject: [PATCH 16/24] build(deps): bump platformdirs in the patch-updates group --- packages/linkml/pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index b1899aba2e..9b307bf814 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "sphinx-click (>=6.0.0)", "openapi-spec-validator >= 0.8.4", "pydantic-settings>=2.15.0", - "platformdirs>=4.11.10", + "platformdirs>=4.11.11", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index a69c907f0f..573b3b6131 100644 --- a/uv.lock +++ b/uv.lock @@ -2511,7 +2511,7 @@ requires-dist = [ { name = "openapi-spec-validator", specifier = ">=0.8.4" }, { name = "openpyxl" }, { name = "parse" }, - { name = "platformdirs", specifier = ">=4.11.10" }, + { name = "platformdirs", specifier = ">=4.11.11" }, { name = "prefixcommons", specifier = ">=0.1.7" }, { name = "prefixmaps", specifier = ">=0.2.2" }, { name = "pydantic", specifier = ">=2.13.5,<3.0.0" }, @@ -3673,11 +3673,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.10" +version = "4.11.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/24/92d90bebedf197eb15b144367ce6fd4ad2de571927cd09dde190a36db8fc/platformdirs-4.11.10.tar.gz", hash = "sha256:9cd351c078ccf7dda1fdc5f8ccb9d8f5258984c63990e6df3627dde0b70b51d0", size = 39389, upload-time = "2026-09-18T01:45:15.107Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/13/f870dd0b42690138e4e37a76b5138e5690ed4365a77071bb092d59037da0/platformdirs-4.11.11.tar.gz", hash = "sha256:b0befe8a90759e4a9a8b9820d434ae226a6549063210b596da0038a7a05aede4", size = 39949, upload-time = "2026-09-19T01:18:47.259Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/45/cae23ec98a49825d58de75c38cdb714605adeb6e6ab72a7ad2b28322892c/platformdirs-4.11.10-py3-none-any.whl", hash = "sha256:973c4c082b964958c71588448488d2b14fb253893dfbdb82325a14c84e540e8f", size = 24763, upload-time = "2026-09-18T01:45:13.878Z" }, + { url = "https://files.pythonhosted.org/packages/39/be/3ae887e84c38c4dd1c549cd5825adc93f48069a94fe1cb8ac52a9da16f15/platformdirs-4.11.11-py3-none-any.whl", hash = "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547", size = 24992, upload-time = "2026-09-19T01:18:45.825Z" }, ] [[package]] From 4ca00228c157930fbfce68d63c121ea670fda6a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:01:26 +0000 Subject: [PATCH 17/24] build(deps-dev): bump tox from 4.61.5 to 4.63.0 --- packages/linkml/pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index 9b307bf814..c5d0d73268 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -97,7 +97,7 @@ dev = [ "nbconvert", "nbformat", "coverage>=7.16.0", - "tox>=4.61.2", + "tox>=4.63.0", "tox-uv", "myst-nb>=1.4.0; python_version >= '3.10'", "sphinx-design >= 0.5.0", diff --git a/uv.lock b/uv.lock index 573b3b6131..fa3c912ccc 100644 --- a/uv.lock +++ b/uv.lock @@ -2563,7 +2563,7 @@ dev = [ { name = "sqlalchemy-bigquery", specifier = ">=1.17.2" }, { name = "testcontainers", specifier = "==4.15.0" }, { name = "tomli-w", specifier = ">=1.2.0" }, - { name = "tox", specifier = ">=4.61.2" }, + { name = "tox", specifier = ">=4.63.0" }, { name = "tox-uv" }, ] docs = [ @@ -5462,7 +5462,7 @@ wheels = [ [[package]] name = "tox" -version = "4.61.5" +version = "4.63.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -5478,9 +5478,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/c8/1a333d5addd65474030ead665d65af8e7a189ebf62983f3b7e4ab2d0627c/tox-4.61.5.tar.gz", hash = "sha256:d1f535a6ca4827c087e0a2a173dd74621d6e1688c740c8dd44e0377480020431", size = 308508, upload-time = "2026-09-17T04:01:49.719Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/93/ce52def83be73dcb86c0c5985f40808917f09828c1616d4018206c3dbba3/tox-4.63.0.tar.gz", hash = "sha256:fb7105ef125b371e31797cfa615e5a160de3d8ef29b86dca7fcbd9048da3a9d8", size = 310965, upload-time = "2026-09-19T16:45:35.275Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/63/50231b6fda8759d4451c377f0c4cfb49f4f339c8a15eee1f186f423f205b/tox-4.61.5-py3-none-any.whl", hash = "sha256:5f9b109fa0a60b7ccb812e8dacbcf7a06b9a633fa90ae97198334b3464b2f133", size = 228128, upload-time = "2026-09-17T04:01:48.182Z" }, + { url = "https://files.pythonhosted.org/packages/1a/4d/d0e37d254b4ad5492d63ea60981365a5122261b5954a9d952d8b15283e26/tox-4.63.0-py3-none-any.whl", hash = "sha256:e8c560a9124e127c5e0547912d2ae1ef948dbba2fdea0b8d506e609a126b2e47", size = 229324, upload-time = "2026-09-19T16:45:33.829Z" }, ] [[package]] From 123bc30ef3a15db006b1144495f456aa5ee26726 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:08:03 -0500 Subject: [PATCH 18/24] build(deps): bump platformdirs in the patch-updates group --- packages/linkml/pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index c5d0d73268..c3014b71f8 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "sphinx-click (>=6.0.0)", "openapi-spec-validator >= 0.8.4", "pydantic-settings>=2.15.0", - "platformdirs>=4.11.11", + "platformdirs>=4.11.12", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index fa3c912ccc..a20aaa68df 100644 --- a/uv.lock +++ b/uv.lock @@ -2511,7 +2511,7 @@ requires-dist = [ { name = "openapi-spec-validator", specifier = ">=0.8.4" }, { name = "openpyxl" }, { name = "parse" }, - { name = "platformdirs", specifier = ">=4.11.11" }, + { name = "platformdirs", specifier = ">=4.11.12" }, { name = "prefixcommons", specifier = ">=0.1.7" }, { name = "prefixmaps", specifier = ">=0.2.2" }, { name = "pydantic", specifier = ">=2.13.5,<3.0.0" }, @@ -3673,11 +3673,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.11.11" +version = "4.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/13/f870dd0b42690138e4e37a76b5138e5690ed4365a77071bb092d59037da0/platformdirs-4.11.11.tar.gz", hash = "sha256:b0befe8a90759e4a9a8b9820d434ae226a6549063210b596da0038a7a05aede4", size = 39949, upload-time = "2026-09-19T01:18:47.259Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/dd/65804b0c2925a1c821a05502ea57517b69a073ff400d25ab9faa3a2cf012/platformdirs-4.11.12.tar.gz", hash = "sha256:e8dc1cb58f1153fd7f61db1374317770baababec2480b37b8f01c6cc25b45267", size = 40016, upload-time = "2026-09-22T01:02:36.973Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/be/3ae887e84c38c4dd1c549cd5825adc93f48069a94fe1cb8ac52a9da16f15/platformdirs-4.11.11-py3-none-any.whl", hash = "sha256:972ea6b2b387155a536226a0750e46923d731d459a387c4a255c583ed2f64547", size = 24992, upload-time = "2026-09-19T01:18:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2b/69de6317fbf48865bb12aa56c9cfcfff46b3add0cd45250725d96146dce0/platformdirs-4.11.12-py3-none-any.whl", hash = "sha256:b1ba966ac76153d9985a0787ffab951e7c18416189873d4e4ddb2c8a04a0a254", size = 24976, upload-time = "2026-09-22T01:02:35.742Z" }, ] [[package]] From 95a39e204382f8e634f79011cf8cdeb7335d77c1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:24:30 +0000 Subject: [PATCH 19/24] build(deps-dev): bump typedb-driver from 3.12.3 to 3.13.6 --- packages/linkml/pyproject.toml | 2 +- uv.lock | 64 +++++++++++++++++----------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index c3014b71f8..98407591cd 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -114,7 +114,7 @@ tests-rustgen = [ "maturin>=1.15.0" ] typedb = [ - "typedb-driver>=3.12.3,<4.0", + "typedb-driver>=3.13.6,<4.0", ] bigquery = [ "sqlalchemy-bigquery >= 1.9.0", diff --git a/uv.lock b/uv.lock index a20aaa68df..356389fd78 100644 --- a/uv.lock +++ b/uv.lock @@ -2607,7 +2607,7 @@ tests-extra = [ { name = "testcontainers", specifier = "==4.15.0" }, ] tests-rustgen = [{ name = "maturin", specifier = ">=1.15.0" }] -typedb = [{ name = "typedb-driver", specifier = ">=3.12.3,<4.0" }] +typedb = [{ name = "typedb-driver", specifier = ">=3.13.6,<4.0" }] typing = [{ name = "numpydantic", specifier = ">=1.10.0" }] [[package]] @@ -5532,42 +5532,42 @@ wheels = [ [[package]] name = "typedb-driver" -version = "3.12.3" +version = "3.13.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parse" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/87/86/6a06a91bbf8275c1464d1c38407fc8e2ac09fb59fd54c0c8e390a583e6f6/typedb_driver-3.12.3-py310-none-macosx_11_0_arm64.whl", hash = "sha256:37ef25026d5a9bc217681efb8c77c11702df0012f713aa60beb777504e39c44f", size = 5601838, upload-time = "2026-08-11T08:58:22.824Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c4/39017ecda70a7664764669e8b2cfaa67a9a9988cf517099a3f0ebe97b613/typedb_driver-3.12.3-py310-none-macosx_11_0_x86_64.whl", hash = "sha256:da9cc070c3f213a02750bfae5588cc48b2294fb5ff9cd7d5f50dc488bac56f41", size = 5729489, upload-time = "2026-08-11T09:01:41.63Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d3/3af880d186e5c5bca81facaa56f06cd817dcf18b82f100fc82b80d547b52/typedb_driver-3.12.3-py310-none-manylinux_2_17_aarch64.whl", hash = "sha256:a7576dd2741360d11746c9b9bb083765e465102e6d934348e1821589100a1b7d", size = 6968847, upload-time = "2026-08-11T09:05:11.621Z" }, - { url = "https://files.pythonhosted.org/packages/f7/86/161974fbe894b82ef946c97c66f95d88603591b19ab1caebf10ea02e998a/typedb_driver-3.12.3-py310-none-manylinux_2_17_x86_64.whl", hash = "sha256:6f76472276e74ad800421025df470b8963d0ebdf87e9d089dbbf6b879516ae10", size = 7119907, upload-time = "2026-08-11T09:03:39.086Z" }, - { url = "https://files.pythonhosted.org/packages/8f/43/362749b34a1c6d068167ee28df75afa592a8d955334a3fe8e0494e5ea3ac/typedb_driver-3.12.3-py310-none-win_amd64.whl", hash = "sha256:dc75bf9313653c4d9f24de51c459af273080fab5233cbf6721d64e99d4107957", size = 3350376, upload-time = "2026-08-11T09:04:36.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bb01715ee44cddd5c89ae9be10518fd04fca9213f172d240fd7d1c5376bb/typedb_driver-3.12.3-py311-none-macosx_11_0_arm64.whl", hash = "sha256:7d9a4faf03a817b647b905e9728cbf6d4bd6ea995e3c69729ea5ef76bb4cb56b", size = 5601838, upload-time = "2026-08-11T08:58:34.817Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/02ee9ef97619870d423383e19c98d075eb6afc1cf621ff29da065ed8dcf8/typedb_driver-3.12.3-py311-none-macosx_11_0_x86_64.whl", hash = "sha256:59a8108832350586af212f2a428ce5ab32757ead8aacfda0c96c1e425be76ac8", size = 5729487, upload-time = "2026-08-11T09:02:00.284Z" }, - { url = "https://files.pythonhosted.org/packages/fb/96/b50902092e1b2e2c444fd97ccbc185da049a71f9243495074cdf6d16c11a/typedb_driver-3.12.3-py311-none-manylinux_2_17_aarch64.whl", hash = "sha256:0a0bdebce80217a04d948fa7ad466d03dd42a0302dcdf5d2d996b4c2219a6977", size = 6969088, upload-time = "2026-08-11T09:05:39.067Z" }, - { url = "https://files.pythonhosted.org/packages/c7/c9/41483f33f1bb6418c87f921ae6338f85c98d1aa2a769dd379fb6c541198b/typedb_driver-3.12.3-py311-none-manylinux_2_17_x86_64.whl", hash = "sha256:3674b4308537c74e6db83797741f6e183e1cae54d6c3d8b370af47d8218a226c", size = 7120037, upload-time = "2026-08-11T09:04:01.788Z" }, - { url = "https://files.pythonhosted.org/packages/42/6b/ec6d8e3f7451b7167c16ded29cbe3fe7d30f79a6c5a54c3974854cc7322b/typedb_driver-3.12.3-py311-none-win_amd64.whl", hash = "sha256:c9462d8a1de395b1c150f81f5e406d58cbeaeb99a255c26148ab1897864b7a82", size = 3350597, upload-time = "2026-08-11T09:05:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/71/8a/611a64b4397cb33c6f185c5613b03e1bcceefe015ead11a6b48260d906e8/typedb_driver-3.12.3-py312-none-macosx_11_0_arm64.whl", hash = "sha256:505905acab3f105b776882b0b9c98aac206ae1878dc6349a911676fb6bbfa917", size = 5602031, upload-time = "2026-08-11T08:58:46.516Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cf/2bdafc70c080d8b56eef89cfb2cb0f93e183f011390bce045490bc209a6f/typedb_driver-3.12.3-py312-none-macosx_11_0_x86_64.whl", hash = "sha256:72d6d9e49f912bb179d7d422dce99b8445692700578ea14341884d864afa1c33", size = 5730506, upload-time = "2026-08-11T09:02:15.084Z" }, - { url = "https://files.pythonhosted.org/packages/eb/7a/fe6ba14d67f31c2f2d5c9018ec195d89523534db51fc2717dd72db88b2bd/typedb_driver-3.12.3-py312-none-manylinux_2_17_aarch64.whl", hash = "sha256:cc1235f98c21a90c514feeda6411eed206fa3ac0b5ec9305eb16cf712eb4b2a8", size = 6970153, upload-time = "2026-08-11T09:06:06.56Z" }, - { url = "https://files.pythonhosted.org/packages/79/ab/6e267e38c85ff37355babfa81f11d56da387e8c5e858e16299f814f84b94/typedb_driver-3.12.3-py312-none-manylinux_2_17_x86_64.whl", hash = "sha256:f581fb24f44eb1ad0f844059d4ab30039cfb4fb4d63270bf89a0e97914cc4dfc", size = 7120201, upload-time = "2026-08-11T09:04:25.329Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0b/a67ad0540e990433623466c4f0aca074ecc980d13cbb26caeb3810e85fc7/typedb_driver-3.12.3-py312-none-win_amd64.whl", hash = "sha256:a68f3298dcc12404923b97e714ba8b0b63f5ae3fe5737d4b06e381e83136eea3", size = 3350900, upload-time = "2026-08-11T09:06:17.051Z" }, - { url = "https://files.pythonhosted.org/packages/01/38/0675edc133823dbe90fceb6e1f6d590dfb94450c12d9592a8cef83a650d3/typedb_driver-3.12.3-py313-none-macosx_11_0_arm64.whl", hash = "sha256:6d2c1ef1b3dbb9ecbc9d9d2f85dc1dddd76978b85d4d87173d5871733989e1be", size = 5602037, upload-time = "2026-08-11T08:58:58.862Z" }, - { url = "https://files.pythonhosted.org/packages/68/19/02a009221ca84aa6de3256849d9a796ed01efa93a4eb8485200c5afefe88/typedb_driver-3.12.3-py313-none-macosx_11_0_x86_64.whl", hash = "sha256:81ff8a85ba1f2d575dbbbf198baedee265e038c3cb9a0779f9652f2c7119db3b", size = 5730510, upload-time = "2026-08-11T09:02:30.111Z" }, - { url = "https://files.pythonhosted.org/packages/c5/25/5f4587311319a50c4c5e722e63666cea5864ae0d30f6c884e6b712db33ac/typedb_driver-3.12.3-py313-none-manylinux_2_17_aarch64.whl", hash = "sha256:ec6908ab04bc39ff678d36e8c28f0d753c50d0a331a649ae0c3559685c20f4f0", size = 6970018, upload-time = "2026-08-11T09:06:34.47Z" }, - { url = "https://files.pythonhosted.org/packages/f6/01/90da9a45b69626b8d64c92d30d313fe5afbef2942518f0da914166828ab8/typedb_driver-3.12.3-py313-none-manylinux_2_17_x86_64.whl", hash = "sha256:d64b72853679269ffa96f3c5da569c31a95fcb949ac8e76cab511a9be48207f7", size = 7120204, upload-time = "2026-08-11T09:04:48.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/d53bdb3a6ae28287c89c52bfe1981bd7cf84a609245a45dbf071be234f09/typedb_driver-3.12.3-py313-none-win_amd64.whl", hash = "sha256:25520782db2ce568cd0f74b8f36e46ebfa10ef4327168f3c8a6b36fcb2d6f1a1", size = 3350230, upload-time = "2026-08-11T09:06:56.742Z" }, - { url = "https://files.pythonhosted.org/packages/a5/2a/40ba00b9c571c9c31b2a7a8aea156c7ac0c9765414e0b4fb0da7813adf74/typedb_driver-3.12.3-py314-none-macosx_11_0_arm64.whl", hash = "sha256:87114e0e4d359cf08da5578d2bd569b9ff58313b1fc65e72d01b4d40644d27e8", size = 5603549, upload-time = "2026-08-11T08:59:13.075Z" }, - { url = "https://files.pythonhosted.org/packages/ee/98/3af6649b5a36cec478f63e50ac6ab6570a224f7d7379c87c3ae5b13e97a4/typedb_driver-3.12.3-py314-none-macosx_11_0_x86_64.whl", hash = "sha256:ecd38f3b81ec2ef38b7d5573156589adb54795dfd14a803cfcdb9222948ce6eb", size = 5731523, upload-time = "2026-08-11T09:02:45.924Z" }, - { url = "https://files.pythonhosted.org/packages/15/ea/981a936fc8506391aac17e21ce58f268076d7463de8c9dfdf028f5242afb/typedb_driver-3.12.3-py314-none-manylinux_2_17_aarch64.whl", hash = "sha256:ad1fc0dd933db02099bfb63414261e3c6e18e7e852112aee4fafd0076092504a", size = 6969831, upload-time = "2026-08-11T09:07:01.773Z" }, - { url = "https://files.pythonhosted.org/packages/99/8c/f96314878ba41cee7f2b1e54cb64f3a0e5ec330b22f9dad863aa6abfdf72/typedb_driver-3.12.3-py314-none-manylinux_2_17_x86_64.whl", hash = "sha256:f71274506eede0ab68d08ee33c5107fb037ba6642ae3e41059716b7ffe8067b1", size = 7120043, upload-time = "2026-08-11T09:05:12.615Z" }, - { url = "https://files.pythonhosted.org/packages/03/fe/b9e6cdd148588d11224631d2e357c73b72f7c30a1e22c0b432c6e483523c/typedb_driver-3.12.3-py314-none-win_amd64.whl", hash = "sha256:b50edcad2ec49ec028118d1775bce9d0b46a2eb96466f157a6e5c5f3acd62657", size = 3350419, upload-time = "2026-08-11T09:07:35.935Z" }, - { url = "https://files.pythonhosted.org/packages/4b/36/5a884b90bab73a61c9d7db21cabbaa014ec02a954e8272ea95784a9e34c9/typedb_driver-3.12.3-py39-none-macosx_11_0_arm64.whl", hash = "sha256:f10e47044e1c81fde8b8e96cbf13cffcfa2460ee30f23249176de6f30d686de9", size = 5602214, upload-time = "2026-08-11T08:58:11.035Z" }, - { url = "https://files.pythonhosted.org/packages/a6/63/bc25d689c6097d5623dc8e297baaa732f0f683229524dca892e5ea3d186f/typedb_driver-3.12.3-py39-none-macosx_11_0_x86_64.whl", hash = "sha256:180784e84a5bed951838a2377bab388dc853ad7c01891ec17c1020b12f954375", size = 5729612, upload-time = "2026-08-11T09:01:23.733Z" }, - { url = "https://files.pythonhosted.org/packages/35/46/0fdc0db8901a6e590afb483ae1e9bd9a849f68b18b67b91ca0603ead745c/typedb_driver-3.12.3-py39-none-manylinux_2_17_aarch64.whl", hash = "sha256:725323fe85ec1d2930f872289bd3416f6b60c5afbb3a70f69c45b5728664edae", size = 6969263, upload-time = "2026-08-11T09:04:42.992Z" }, - { url = "https://files.pythonhosted.org/packages/49/61/076f5b11686a68250650927abe49ad5a1a12759087cfa531469b72b3b731/typedb_driver-3.12.3-py39-none-manylinux_2_17_x86_64.whl", hash = "sha256:8cc32c6f98effa751f77760b30caf959cbafa54722bc7d319fa467e6601144eb", size = 7119455, upload-time = "2026-08-11T09:03:16.082Z" }, - { url = "https://files.pythonhosted.org/packages/20/4e/583a2ecbaa11a3682532250fac91f0c71fec8ef3c5654020f704c9c6f436/typedb_driver-3.12.3-py39-none-win_amd64.whl", hash = "sha256:c413fb496aa64fbedf39d6528119134634f060e7bdc63eba94fad6f251da8f99", size = 3350179, upload-time = "2026-08-11T09:03:50.17Z" }, + { url = "https://files.pythonhosted.org/packages/60/b2/e57f7e1e752c5f5175bd53080f60429a99b115bc06991a4205785312be54/typedb_driver-3.13.6-py310-none-macosx_11_0_arm64.whl", hash = "sha256:b807c67b5366956020add5b3aac06fca53fee6bf38b65c7b8b162a46765271df", size = 5609454, upload-time = "2026-09-21T19:29:15.196Z" }, + { url = "https://files.pythonhosted.org/packages/08/25/80f2d6bd907f0e37f79b6edc16bbf1ca298d33af326457adf0d40334ad66/typedb_driver-3.13.6-py310-none-macosx_11_0_x86_64.whl", hash = "sha256:ec70a0843e0c2ffe8ce7c728ea4d44b31ad381dd2a4467a1df8e9243f58d6532", size = 5732150, upload-time = "2026-09-21T19:32:50.551Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ea/3b1414f3fc189ef1aa9703b48fc80c3ea5de8467013e15549bf9201dae0d/typedb_driver-3.13.6-py310-none-manylinux_2_17_aarch64.whl", hash = "sha256:c4ee34e0e1da020c36be4f6d211381004ee2104ebd6dc4699f1469f1a5906146", size = 6975141, upload-time = "2026-09-21T19:35:20.759Z" }, + { url = "https://files.pythonhosted.org/packages/d0/4a/b993ddacc369c31fc1d3291228029cb430914367d84e5441bb8b3a991e64/typedb_driver-3.13.6-py310-none-manylinux_2_17_x86_64.whl", hash = "sha256:1469a21652728e8269a4df6d1bdaa401711211597e9ab9109070332fb3922a9d", size = 7115364, upload-time = "2026-09-21T19:34:24.377Z" }, + { url = "https://files.pythonhosted.org/packages/16/4d/62fad5c54005cf3cb7da0c786929dc4ed767e5e7844174836bc43cbb68e0/typedb_driver-3.13.6-py310-none-win_amd64.whl", hash = "sha256:952cccb36589894a6a3c165d6f1fd5cb138aa66331da62cf6049e491055b4040", size = 3349496, upload-time = "2026-09-21T19:35:03.223Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7a/6852b88ece2962902b49209e08e019d3e79d9f385357dd77efcfedf274c8/typedb_driver-3.13.6-py311-none-macosx_11_0_arm64.whl", hash = "sha256:bc683e0688da2b32c3d29f175f79bbc68ffd05907337c9d6767315700f0235db", size = 5609459, upload-time = "2026-09-21T19:29:25.891Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bc/c874b9053179cc97c0edb56dedda54ef43df0b3d2bc5ea5c51c843e78916/typedb_driver-3.13.6-py311-none-macosx_11_0_x86_64.whl", hash = "sha256:fecf7ead8be3443e46149a45dfdbba0e9a3ebd5564344cd0b52e542e41a526e3", size = 5732152, upload-time = "2026-09-21T19:33:06.313Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cd/11df19fcda2978709be84d67438cebaf5c79e314ab749790e62a3fc32e56/typedb_driver-3.13.6-py311-none-manylinux_2_17_aarch64.whl", hash = "sha256:8a94a7fd660c58a8a1b8b58622a861a95442de1e82750db79c1cfbaaeffb1f3b", size = 6975387, upload-time = "2026-09-21T19:35:39.156Z" }, + { url = "https://files.pythonhosted.org/packages/ba/46/fa6fee0f4fc2cd17539a9522600809a35c4aec250ed50096281d6f81f878/typedb_driver-3.13.6-py311-none-manylinux_2_17_x86_64.whl", hash = "sha256:6e473226335b1f8d8fe0f34c8574d62967df2b08bf3c69badfcceec07d5367f7", size = 7115178, upload-time = "2026-09-21T19:34:41.474Z" }, + { url = "https://files.pythonhosted.org/packages/59/d6/a9fe128fb7c85d1d26afc5e0af1d5910fa56775a4133eb3dcbd49a24a72a/typedb_driver-3.13.6-py311-none-win_amd64.whl", hash = "sha256:a9cbcaec71964d52991b86fdbcb11586f0435c4c064b047bd8fd58021ce5c0e2", size = 3349396, upload-time = "2026-09-21T19:35:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/20/9498738b2247a3de55c870b850703a74a3fcfdad4c8a4a61a9ee58dc15ff/typedb_driver-3.13.6-py312-none-macosx_11_0_arm64.whl", hash = "sha256:e106def5b6b92b2b5c0fbf69f3c238d146f207208dcf85fd88320a1ce9505b4c", size = 5608252, upload-time = "2026-09-21T19:29:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/da/32/a2549807205c84c433181d595c56e4c816e43faed17ce849e052a06634d8/typedb_driver-3.13.6-py312-none-macosx_11_0_x86_64.whl", hash = "sha256:a23bfa6f98eb5c7843b5f3192278849396efd087e9bed7cedc06066bdb3441e7", size = 5733740, upload-time = "2026-09-21T19:33:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5e/b81eb9d5bbc5049e3ebe0f4d53c7d0e6c988e1fb752066bbead7aa8a85d8/typedb_driver-3.13.6-py312-none-manylinux_2_17_aarch64.whl", hash = "sha256:48a1f4cc62b59efda52a6db878ae64177c83c8c43666283b80426779745e1969", size = 6976437, upload-time = "2026-09-21T19:35:57.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/8be5b574db95a77c9b2e182c9991df29c6ea02e3bbd5cd9f405838247622/typedb_driver-3.13.6-py312-none-manylinux_2_17_x86_64.whl", hash = "sha256:e973362490970b82c04b5239f96ea1689440f2d7ca6e5d28986944af266e982c", size = 7115516, upload-time = "2026-09-21T19:35:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/86/76/8d8bd4385521f9db08d42b1a52373c5cd31ec99f979535e99390ec6626c3/typedb_driver-3.13.6-py312-none-win_amd64.whl", hash = "sha256:07add5e3fe7a1962c5c45b9805b1556422da1330e62fbe1434d6d5e99b0f4b7c", size = 3350079, upload-time = "2026-09-21T19:35:54.05Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ec/3f82d9320cab8259b77752e012760c3cab4a1e4530c131728e1f7445f6f8/typedb_driver-3.13.6-py313-none-macosx_11_0_arm64.whl", hash = "sha256:f3d6916a11594e2e113b3c129a5caf25dbb93e4df1324081cc982820416c086b", size = 5608256, upload-time = "2026-09-21T19:29:47.807Z" }, + { url = "https://files.pythonhosted.org/packages/a9/aa/3213db200a4c0b0917abacd3fb9a15cfa0caa67799f5fd53d7479db487d3/typedb_driver-3.13.6-py313-none-macosx_11_0_x86_64.whl", hash = "sha256:74057c4e52f7e69d3b51b878e503dfec296c4fb855d1365c553960ca08a36eba", size = 5733736, upload-time = "2026-09-21T19:33:41.716Z" }, + { url = "https://files.pythonhosted.org/packages/9a/65/bc749fd845d2b3fef4a817bb83e6401adb9c706d27c2818225b347721e08/typedb_driver-3.13.6-py313-none-manylinux_2_17_aarch64.whl", hash = "sha256:f1e974592e52851a1a4e6ecd8c02c08bde7687c05cab867d4ca2d49a57e150a0", size = 6976507, upload-time = "2026-09-21T19:36:17.474Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/e1ac50c83353d5fd09fe9d203abefed28739820ae409dbb6356ae6f6a626/typedb_driver-3.13.6-py313-none-manylinux_2_17_x86_64.whl", hash = "sha256:01bca56e46ad680e93be6921744c27c48b718b09c55240c45af42826e7d6c0e9", size = 7115509, upload-time = "2026-09-21T19:35:18.668Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/c046a30b8ac602604fff33b92e1591140bcf4ccc734ed92f22f9bf8522b8/typedb_driver-3.13.6-py313-none-win_amd64.whl", hash = "sha256:53bf76070810c5f5a7befe2a56788ed634e8bcf148e32720a80a76f790738a1c", size = 3349438, upload-time = "2026-09-21T19:36:43.329Z" }, + { url = "https://files.pythonhosted.org/packages/03/47/a3ba965326e282fe9a4052564ac0a98c2f6ce047b860ae0ed13d5693688e/typedb_driver-3.13.6-py314-none-macosx_11_0_arm64.whl", hash = "sha256:64f66837055646c6c4f306cb43e5e89d8665089567fb6e696f5a503982f948a1", size = 5609292, upload-time = "2026-09-21T19:29:58.459Z" }, + { url = "https://files.pythonhosted.org/packages/79/da/1d7161dc83186ef6862791a821764cd7889b42feba4254f4b448823cde90/typedb_driver-3.13.6-py314-none-macosx_11_0_x86_64.whl", hash = "sha256:6dbf9769ea7182824de376968bd0822968bd9d4a4c70ff15d4c8aad5a6e56614", size = 5734850, upload-time = "2026-09-21T19:33:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/5e/92/6f72b686cfc7191520f6ba850e6fda98f6b4531fed230b69102562d4c282/typedb_driver-3.13.6-py314-none-manylinux_2_17_aarch64.whl", hash = "sha256:2f6a2ada1f6822c8d083aeccb616aa07fc250d54bed62e9d0d3229cbc5ac12a8", size = 6976125, upload-time = "2026-09-21T19:36:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/9b/dc/d43d68b12088e59af16c5dca45818fd4fca7e1646ce598a38e70b3b4c13a/typedb_driver-3.13.6-py314-none-manylinux_2_17_x86_64.whl", hash = "sha256:7bde2aeeb31815074cc92a4d8852c12d32e8c5d90a2bac5329998ee70fccbcd9", size = 7115518, upload-time = "2026-09-21T19:35:35.615Z" }, + { url = "https://files.pythonhosted.org/packages/8d/94/0de1fb67ab1c22bfbd951860f142a147e955cdcd768ba3a68ce96a751961/typedb_driver-3.13.6-py314-none-win_amd64.whl", hash = "sha256:3e19b9a22a65ef375ea5c034cc27b3d6895483fd556e02fbc488d90101170ad2", size = 3349125, upload-time = "2026-09-21T19:37:21.029Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c6/e3933b588cc76348d52594e1bf42d893b8db6afcf33f1aa5d66cabbed9ed/typedb_driver-3.13.6-py39-none-macosx_11_0_arm64.whl", hash = "sha256:93b4a46d62fee89ee01cf1ba67a5660bcc3b0034d45345cac92157969ff36feb", size = 5609414, upload-time = "2026-09-21T19:29:02.088Z" }, + { url = "https://files.pythonhosted.org/packages/5a/40/57865b73b8adfa5d254349fb5536aaaa3f2303e774e85135b3dd4753ab1f/typedb_driver-3.13.6-py39-none-macosx_11_0_x86_64.whl", hash = "sha256:235e23be393b7b8438da40a1debd278924a4c949942f81638254a6c500672fa5", size = 5732261, upload-time = "2026-09-21T19:32:28.154Z" }, + { url = "https://files.pythonhosted.org/packages/05/ee/f414294cf5d1b640a62beef9c5f4f44e0ee75c2a92919727740319633917/typedb_driver-3.13.6-py39-none-manylinux_2_17_aarch64.whl", hash = "sha256:bc6468202f778700f76dc958c9811b7f791ae51607395c21e1fc72d44db67fed", size = 6975077, upload-time = "2026-09-21T19:35:00.458Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/6ef6ffc56efbbf139da7e54550187f728b0d37a3594ffb724be1efde667a/typedb_driver-3.13.6-py39-none-manylinux_2_17_x86_64.whl", hash = "sha256:82ad7bb2858821bfd5c559e530e6c2bff566b0d54d6c7dbf5b5b5eca326ef0f1", size = 7115763, upload-time = "2026-09-21T19:34:07.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/bc/e92b8eb0d046b998f11cb101e82df33ac1bcbbb4d9f5349743d56efebb34/typedb_driver-3.13.6-py39-none-win_amd64.whl", hash = "sha256:f6675e5f89307bdae5ccdabe3513860e3ac1a51901db4e5d125323fb8bd0ae90", size = 3349307, upload-time = "2026-09-21T19:34:32.772Z" }, ] [[package]] From fd4ed996752d2bb233cbe307f54fe91d85a8b463 Mon Sep 17 00:00:00 2001 From: Silvano Cirujano Cuesta Date: Tue, 29 Sep 2026 22:49:04 +0200 Subject: [PATCH 20/24] feat(openapigen): prepare support for multiple openapi versions * test(openapigen): parametrize OAS version in templates and fixture Make the top-level \`openapi\` attribute of the composed OpenAPI templates a parameter instead of hardcoding '3.0.3'. The OPENAPI_HEADER fragment now takes an oas_version placeholder, threaded through the openapi_template and single_endpoint_template composition helpers (defaulting to 3.0.3). The openapi_spec fixture is parametrized over the OAS_VERSIONS mapping (OpenAPI version string -> OpenApiGenerator output format name) and wires the chosen format into the generator via gen_openapi_spec. The TEMPLATE_HEAD constant is refactored into the template_head() helper so the fixture and the standalone tests compose the same template. Only 3.0.3 is driven so far; adding a new OAS version is a one-line extension of OAS_VERSIONS. Signed-off-by: Silvano Cirujano Cuesta * feat(openapigen): prepare support for multiple openapi version Generalize the generator so the OpenAPI version is not hard-coded to v3.0.3. The version is now read from the template's top-level `openapi` attribute and validated against the list of supported versions, paving the way for v3.1.0 support. Signed-off-by: Silvano Cirujano Cuesta * feat(openapigen): add support for openapi v3.1.0 When the template declares OpenAPI v3.1.0, schemas are now generated via PydanticGenerator instead of JsonSchemaGenerator. Because OpenAPI 3.1.0 is fully aligned with JSON Schema 2020-12, the only post-processing needed is rewriting `$defs` references to `components/schemas` and stripping `linkml_meta` annotations. Schema names invalid under OpenAPI 3.1 are sanitized and their `$ref`s rewritten. Adds v3.1.0-specific test fixtures and docs for the two supported versions. Signed-off-by: Silvano Cirujano Cuesta * test(openapigen): parametrize version-agnostic tests over both OAS versions --------- Signed-off-by: Silvano Cirujano Cuesta Co-authored-by: N <13322818+noelmcloughlin@users.noreply.github.com> --- docs/generators/openapi.rst | 25 +- .../src/linkml/generators/openapigen.py | 258 ++++-- ...penapi.yaml => spec-head-v30.openapi.yaml} | 0 .../input/openapi/spec-head-v31.openapi.yaml | 58 ++ .../linkml/test_generators/test_openapigen.py | 767 +++++++++++------- tests/linkml/test_scripts/test_gen_openapi.py | 21 +- 6 files changed, 768 insertions(+), 361 deletions(-) rename tests/linkml/test_generators/input/openapi/{spec-head.openapi.yaml => spec-head-v30.openapi.yaml} (100%) create mode 100644 tests/linkml/test_generators/input/openapi/spec-head-v31.openapi.yaml diff --git a/docs/generators/openapi.rst b/docs/generators/openapi.rst index d120e70296..dc5010999b 100644 --- a/docs/generators/openapi.rst +++ b/docs/generators/openapi.rst @@ -2,8 +2,9 @@ OpenAPI ======= `OpenAPI `_ is a specification for describing -RESTful HTTP APIs. The OpenAPI generator produces an OpenAPI v3.0.3 -specification in YAML from a LinkML schema. +RESTful HTTP APIs. The OpenAPI generator produces an OpenAPI +specification in YAML from a LinkML schema. As of now it supports OpenAPI +specification versions v3.0.3 and v3.1.0. .. note:: This generator produces a complete OpenAPI spec by combining a user-provided *template* (containing the API header, endpoints, and @@ -15,15 +16,16 @@ Overview The generator works in two stages: -1. The user provides an **OpenAPI template** — a valid OpenAPI v3.0.3 YAML +1. The user provides an **OpenAPI template** — a valid OpenAPI YAML file that defines the API metadata (title, version, servers), paths - (endpoints), and security schemes. + (endpoints), and security schemes. It also specifies the version of + the OpenAPI specification in its top-level attribute `openapi`. 2. The generator fills the ``components/schemas`` section with JSON Schema definitions generated from the LinkML schema, keeping only those classes that are transitively reachable from the endpoints. Both the input template and the final output are automatically validated -against the OpenAPI 3.0.3 specification using +against the corresponding OpenAPI specification version using `openapi-spec-validator `_. To run: @@ -32,6 +34,9 @@ To run: gen-openapi personinfo.yaml --template api-template.yaml > personinfo.openapi.yaml +The **template's top-level attribute `openapi` MUST specify the supported OpenAPI +version**. + The ``--template`` / ``-t`` option is required when generating a concrete specification. If omitted, the generator prints a generic template that can be used as a starting point: @@ -47,14 +52,8 @@ The generator validates both the input template and the final output against the OpenAPI specification using `openapi-spec-validator `_. -The ``openapi`` field in the template is checked against the expected -version for the chosen output format (currently ``openapi303`` → -``3.0.3``). If the versions do not match, the generator raises a -``ValueError``. - -Additionally, the template's ``components/schemas`` section must declare each -resource that is referenced by an endpoint, using two custom extension -fields: +The template's ``components/schemas`` section must declare each resource +that is referenced by an endpoint, using two custom extension fields: ``x-linkml-schema`` The ``id`` of the LinkML schema being used. Must match exactly. diff --git a/packages/linkml/src/linkml/generators/openapigen.py b/packages/linkml/src/linkml/generators/openapigen.py index 999b14d4af..813506e41f 100644 --- a/packages/linkml/src/linkml/generators/openapigen.py +++ b/packages/linkml/src/linkml/generators/openapigen.py @@ -1,4 +1,4 @@ -"""Generate OpenAPI v3.0.3 Specification YAML files.""" +"""Generate OpenAPI YAML files.""" import json import os @@ -10,21 +10,28 @@ import click import yaml -from openapi_spec_validator import OpenAPIV30SpecValidator +from openapi_spec_validator import OpenAPIV30SpecValidator, OpenAPIV31SpecValidator from openapi_spec_validator import validate as openapi_validate from openapi_spec_validator.validation.validators import SpecValidator as OaSpecValidator +from pydantic import BaseModel from yaml import MappingNode, ScalarNode from linkml._version import __version__ from linkml.generators.jsonschemagen import JsonSchemaGenerator, json_schema_types +from linkml.generators.pydanticgen import PydanticGenerator from linkml.utils.generator import Generator, shared_arguments -openapi_generic_template = """openapi: 3.0.3 +SUPPORTED_OPENAPI_VERSIONS = ["3.0.3", "3.1.0"] + +openapi_generic_template = """# TODO: remove this whole comment block after processing # This is a valid OpenAPI template to be used by the LinkML OpenAPI generator. +# Make sure to set the right OpenAPI version in the `openapi` top-level attribute. +# These are the supported OpenAPI versions: {openapi_version_list} # It adds one (random) class or type of the LinkML schema as an example. # Please adapt it to your needs. # See more information in the online documentation: # https://linkml.io/linkml/generators/openapi.html +openapi: x.y.z info: title: Generic example referring in LinkML-modelled resources version: 0.1.0 @@ -41,20 +48,24 @@ content: application/json: schema: + # TODO: remove this whole comment block after processing # any broken reference will cause template instantiation to fail # OpenAPI editors typically also report them $ref: '#/components/schemas/{data_schema}' components: + # TODO: remove this whole comment block after processing # any data schema provided here that is not used by at least # one endpoint will be eliminated from the template instantiation # OpenAPI editors typically also report them schemas: + # TODO: remove this whole comment block after processing # this resource name can differ from the name in the LinkML schema # it must only match the corresponding endpoint `$ref` references # it creates a mapping between names in OpenAPI and LinkML {data_schema}: type: object description: Resource schema to be generated from the LinkML data model. + # TODO: remove this whole comment block after processing # schema ID mismatching with provided schema will cause template # instantiation to fail x-linkml-schema: {linkml_schema_id} @@ -65,18 +76,32 @@ @dataclass class OpenApiGenerator(Generator): """ - Generates OpenAPI v3.0.3 specification YAML from a LinkML schema. + Generates OpenAPI YAML from a LinkML schema. The generator composes a user-provided OpenAPI template (containing the API header, paths/endpoints, and security schemes) with JSON Schema components generated from the LinkML schema via :class:`.JsonSchemaGenerator`. Only data schemas referenced by the template's endpoints (and their transitive dependencies) are included in the ``components/schemas`` section. + + Currently following generation paths are supported (others might follow): + + * **v3.0.3** — uses :class:`.JsonSchemaGenerator` and applies post-processing + transforms (``const`` → ``enum``, nullable ``type`` lists → ``anyOf``, + ``$defs`` → ``components/schemas``) required by OpenAPI 3.0.3. + * **v3.1.0** — uses :class:`.PydanticGenerator` to compile a Python module, + then calls :meth:`pydantic.BaseModel.model_json_schema` on each class. + Because OpenAPI 3.1.0 is fully aligned with JSON Schema 2020-12, no + post-processing transforms are needed beyond rewriting ``$defs`` references + and stripping ``linkml_meta`` annotations. + + The OpenAPI version to be generated is obtained from the template's top-level + attribute `openapi`. """ generatorname = os.path.basename(__file__) generatorversion = "0.2.0" - valid_formats = ["openapi303"] + valid_formats = ["openapi"] file_extension = "yaml" uses_schemaloader = False @@ -85,30 +110,23 @@ class OpenApiGenerator(Generator): inline_enums: bool = False # Mapping of valid_formats entries to OpenAPI version strings. # Extend this dict when adding support for additional OpenAPI versions. - _openapi_versions: dict[str, str] = field( - default_factory=lambda: {"openapi303": "3.0.3"}, + _openapi_versions: list[str] = field( + default_factory=lambda: SUPPORTED_OPENAPI_VERSIONS, init=False, repr=False, ) # Mapping of OpenAPI version strings to validators from openapi-spec-validator. # Extend this dict when adding support for additional OpenAPI versions. _openapi_validators: dict[str, type[OaSpecValidator]] = field( - default_factory=lambda: {"3.0.3": OpenAPIV30SpecValidator}, + default_factory=lambda: {"3.0.3": OpenAPIV30SpecValidator, "3.1.0": OpenAPIV31SpecValidator}, init=False, repr=False, ) - def _validate_oad_template( - self, oad_validator_class: type[OaSpecValidator], expected_version: str, format_name: str - ): + _openapi_version = "" # OpenAPI version declared in the template + + def _validate_oad_template(self, oad_validator_class: type[OaSpecValidator], expected_version: str): """Validate the OpenAPI template""" - # Validate that the template declares the expected OpenAPI version - declared_version = self._template.get("openapi") - if declared_version != expected_version: - raise ValueError( - f"Template OpenAPI version is '{declared_version}', " - f"but format '{format_name}' requires version '{expected_version}'" - ) # Validate the input template against the OpenAPI specification. # This also catches dangling $ref targets in endpoints. openapi_validate(self._template, cls=oad_validator_class) @@ -214,11 +232,11 @@ def _reachable_from_seeds(self, ref_map: dict[str, set[str]], seeds: set[str]) - stack.append(ref) return seen - def _fix_openapi_spec(self, element: dict | list) -> dict | list: + def _fix_openapi_spec_v303(self, element: dict | list) -> dict | list | None: """ Transform JSON Schema constructs into OpenAPI v3.0.3 compatible forms: - - ``const`` becomes ``enum`` with a single value (OpenAPI 3.0 doesn't support ``const``) + - ``const`` becomes ``enum`` with a single value - ``type`` as a list (e.g. nullable ``["string", "null"]``) becomes ``anyOf`` - ``examples`` (a list) becomes ``example`` (its first element); OpenAPI 3.0 has no plural ``examples`` keyword on the Schema Object, only singular ``example`` @@ -241,7 +259,7 @@ def _fix_openapi_spec(self, element: dict | list) -> dict | list: fixed_element["example"] = value[0] else: if isinstance(value, dict | list): - value = self._fix_openapi_spec(value) + value = self._fix_openapi_spec_v303(value) elif isinstance(value, str) and value.startswith("#/$defs/"): value = value.replace("#/$defs/", "#/components/schemas/") fixed_element[key] = value @@ -249,7 +267,7 @@ def _fix_openapi_spec(self, element: dict | list) -> dict | list: fixed_element = [] for item in element: if isinstance(item, dict | list): - item = self._fix_openapi_spec(item) + item = self._fix_openapi_spec_v303(item) elif isinstance(item, str) and item.startswith("#/$defs/"): item = item.replace("#/$defs/", "#/components/schemas/") fixed_element.append(item) @@ -287,10 +305,47 @@ def _rename(self, name_map: dict[str, str], element: dict | list) -> dict | list raise TypeError(f"Unexpected type '{type(element)}', only 'dict' and 'list' supported.") return renamed_element + def _strip_linkml_meta(self, element: dict | list) -> dict | list: + """Remove ``linkml_meta`` annotations recursively from Pydantic JSON Schema output.""" + if isinstance(element, dict): + element.pop("linkml_meta", None) + for value in element.values(): + if isinstance(value, dict) or isinstance(value, list): + self._strip_linkml_meta(value) + elif isinstance(element, list): + for item in element: + if isinstance(item, dict) or isinstance(item, list): + self._strip_linkml_meta(item) + return element + + def _rewrite_defs_refs(self, element: dict | list) -> dict | list: + """ + Rewrite ``#/$defs/`` references to ``#/components/schemas/`` in-place. + + This is the only structural transformation needed for OpenAPI 3.1.0, + since it is fully aligned with JSON Schema 2020-12. + """ + if isinstance(element, dict): + keys_to_update = [] + for key, value in element.items(): + if isinstance(value, str) and value.startswith("#/$defs/"): + keys_to_update.append((key, value.replace("#/$defs/", "#/components/schemas/"))) + elif isinstance(value, dict) or isinstance(value, list): + self._rewrite_defs_refs(value) + for key, new_value in keys_to_update: + element[key] = new_value + elif isinstance(element, list): + for i, item in enumerate(element): + if isinstance(item, str) and item.startswith("#/$defs/"): + element[i] = item.replace("#/$defs/", "#/components/schemas/") + elif isinstance(item, dict) or isinstance(item, list): + self._rewrite_defs_refs(item) + return element + def _sanitize_schemas(self, name_map: dict[str, str], elem_schemas: dict, req_linkml_names: set[str]) -> dict: """ Prune unreachable schemas, remove redundant metadata, convert JSON Schema constructs - to OpenAPI 3.0.3 compat, and apply any OpenAPI↔LinkML name renames. + to OpenAPI 3.0.3 compat, and apply any OpenAPI<->LinkML name renames. """ # Keep only schemas transitively reachable from the endpoint-referenced seeds. # The reference graph is built once and traversed in a single pass; no fixpoint @@ -303,7 +358,18 @@ def _sanitize_schemas(self, name_map: dict[str, str], elem_schemas: dict, req_li # title always duplicates the schema dict key, so it is redundant in components/schemas for elem_schema in elem_schemas.values(): elem_schema.pop("title", None) - elem_schemas = cast(dict, self._fix_openapi_spec(elem_schemas)) + if self._openapi_version == "3.0.3": + elem_schemas = cast(dict, self._fix_openapi_spec_v303(elem_schemas)) + elif self._openapi_version == "3.1.0": + elem_schemas = cast(dict, self._strip_linkml_meta(elem_schemas)) + elem_schemas = cast(dict, self._rewrite_defs_refs(elem_schemas)) + # OpenAPI 3.1 restricts components/schemas keys to ^[a-zA-Z0-9._-]+$ + # (no spaces). Sanitize offending schema names and rewrite every $ref. + sanitize_map = self._sanitize_schema_names(elem_schemas, reserved=set(name_map.values())) + if sanitize_map: + elem_schemas = cast(dict, self._rename(sanitize_map, elem_schemas)) + else: + raise ValueError(f"OpenAPI version '{self._openapi_version}' is not supported") if self.inline_enums: # inline before renaming so the enum/type guard matches LinkML names, # not the (possibly renamed) OpenAPI schema names @@ -312,6 +378,32 @@ def _sanitize_schemas(self, name_map: dict[str, str], elem_schemas: dict, req_li elem_schemas = cast(dict, self._rename(name_map, elem_schemas)) return elem_schemas + # OpenAPI 3.1 schema-name pattern; keys under components/schemas must match it. + _OPENAPI_31_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$") + + def _sanitize_schema_names(self, openapi_schemas: dict, reserved: set[str]) -> dict[str, str]: + """Return a map of schema names invalid under OpenAPI 3.1 to sanitized equivalents. + + OpenAPI 3.1 constrains ``components/schemas`` keys to ``^[a-zA-Z0-9._-]+$``, + so LinkML names containing spaces (or other disallowed characters) must be + rewritten. Any run of invalid characters collapses to a single underscore; + uniqueness is ensured against existing and already-reserved names. + """ + existing = set(openapi_schemas.keys()) | reserved + name_map: dict[str, str] = {} + for name in openapi_schemas: + if self._OPENAPI_31_NAME_RE.match(name): + continue + base = re.sub(r"[^a-zA-Z0-9._-]+", "_", name).strip("_") or "schema" + candidate = base + suffix = 1 + while candidate in existing or candidate in name_map.values(): + candidate = f"{base}_{suffix}" + suffix += 1 + name_map[name] = candidate + existing.add(candidate) + return name_map + def _inline_enum_schemas(self, data_schemas: dict, endpoint_schemas: set[str] | None = None) -> dict: """Inline enum subschemas into their parents instead of separate entries. @@ -389,26 +481,93 @@ def _collect_refs(obj: dict | list) -> list[str]: refs.extend(OpenApiGenerator._collect_refs(item)) return refs + def _generate_schemas_v303(self, endpoint_ref_schema_names: set[str]) -> dict: + """Generate component schemas for OpenAPI v3.0.3 via :class:`.JsonSchemaGenerator`.""" + # JsonSchemaGenerator.generate() emits every class/enum of the LinkML schema into + # $defs. LinkML types are not part of $defs and are generated separately. + # all_req_schemas contains all directly or transitively required schemas from + # LinkML classes and types + # not_closed=True is deliberate: APIs are extended backwards-compatibly by + # adding attributes to existing objects, which additionalProperties=False + # blocks. Stated explicitly rather than inherited from the generator default, + # which follows the metamodel and closes classes with no `extra_slots`. + json_schema = JsonSchemaGenerator( + self.schemaview.schema, include_null=False, preserve_names=True, not_closed=True + ).generate() + all_req_schemas: dict[str, dict] = json.loads(json_schema.to_json())["$defs"] + for linkml_name in endpoint_ref_schema_names: + if linkml_name in self.schemaview.all_types(): + all_req_schemas[linkml_name] = self._generate_type_schema(linkml_name) + return all_req_schemas + + def _generate_schemas_v310(self, endpoint_ref_schema_names: set[str]) -> dict: + """Generate component schemas for OpenAPI v3.1.0 via :class:`.PydanticGenerator`.""" + if not endpoint_ref_schema_names: + return {} + materialized_schema = self.schemaview.materialize_derived_schema() + module = PydanticGenerator(materialized_schema, extra_fields="allow").compile_module() + pydantic_classes = { + name: obj + for name, obj in vars(module).items() + if isinstance(obj, type) and issubclass(obj, BaseModel) and obj is not BaseModel + } + defined_types = {name: obj for name, obj in vars(module)["linkml_meta"]["types"].items()} + + all_schemas = {} + for name, cls in pydantic_classes.items(): + schema = cls.model_json_schema() + if "$defs" in schema: + all_schemas |= cls.model_json_schema()["$defs"] + if defined_types: + # not_closed=True is deliberate, mirroring the v3.0.3 path: APIs are extended + # backwards-compatibly by adding attributes to existing objects, which + # additionalProperties=False blocks. The generated class schemas above are open + # (extra_fields="allow"); this JsonSchema merge must not clobber them with the + # metamodel-default closed class schemas. + json_schema = JsonSchemaGenerator( + self.schemaview.schema, include_null=False, preserve_names=True, not_closed=True + ).generate() + all_schemas |= json.loads(json_schema.to_json())["$defs"] + + # LinkML types are not emitted as standalone Pydantic classes nor reliably as + # JSON Schema $defs (their constraints are inlined into referencing slots). + # Endpoint-referenced types must therefore be generated explicitly, mirroring + # the v3.0.3 path. + for linkml_name in endpoint_ref_schema_names: + if linkml_name not in all_schemas and linkml_name in self.schemaview.all_types(): + all_schemas[linkml_name] = self._generate_type_schema(linkml_name) + + return all_schemas + + def _generate_schemas(self, endpoint_ref_schema_names: set[str]) -> dict: + if self._openapi_version == "3.1.0": + all_req_schemas = self._generate_schemas_v310(endpoint_ref_schema_names) + else: + all_req_schemas = self._generate_schemas_v303(endpoint_ref_schema_names) + return all_req_schemas + def serialize(self, template_file: str = "", **kwargs) -> str: - """Generate an OpenAPI v3.0.3 spec from ``template_file`` and the loaded LinkML schema.""" + """Generate OpenAPI YAML from ``template_file`` and the loaded LinkML schema.""" # load the template if not template_file: raise ValueError("An OpenAPI template file is required") with open(template_file) as tf: template_text = tf.read() self._template = yaml.safe_load(template_text) - # determine the expected OpenAPI version from the active output format - format_name = getattr(self, "format", self.valid_formats[0]) or self.valid_formats[0] - expected_version = self._openapi_versions.get(format_name) - if expected_version is None: - raise ValueError(f"Unsupported output format '{format_name}'") + # determine the OpenAPI version from the provided template + self._openapi_version = self._template["openapi"] + if self._openapi_version not in SUPPORTED_OPENAPI_VERSIONS: + raise ValueError( + f"Unsupported OpenAPI version {self._openapi_version}. " + + f"Only supported versions are {','.join(self._openapi_versions)}" + ) # get the corresponding OpenAPI validator - oad_validator_class = self._openapi_validators.get(expected_version) + oad_validator_class = self._openapi_validators.get(self._openapi_version) if oad_validator_class is None: - raise ValueError(f"No validator available for OpenAPI version {expected_version}") + raise ValueError(f"No validator available for OpenAPI version {self._openapi_version}") # validate the OpenAPI template before further processing - self._validate_oad_template(oad_validator_class, expected_version, format_name) + self._validate_oad_template(oad_validator_class, self._openapi_version) # if no schemas to instantiate, return the template itself if ( "components" not in self._template @@ -429,28 +588,17 @@ def serialize(self, template_file: str = "", **kwargs) -> str: req_linkml_names: set[str] = {openapi_schemas[n]["x-linkml-source"] for n in openapi_schemas.keys()} else: req_linkml_names: set[str] = {openapi_schemas[n]["x-linkml-source"] for n in endpoint_ref_openapi_names} - # when OpenAPI and LinkML names differ, record the synonym for later renaming + # when OpenAPI and LinkML names differ, record the synonym for later renaming. + # The template may declare a resource name (x-linkml-source mapping) for schemas + # referenced only by other schemas, not just those referenced directly by + # endpoints; every declared mapping must be honoured throughout the spec. name_map: dict[str, str] = { openapi_schemas[n]["x-linkml-source"]: n - for n in endpoint_ref_openapi_names + for n in openapi_schemas if n != openapi_schemas[n]["x-linkml-source"] } - # JsonSchemaGenerator.generate() emits every class/enum of the LinkML schema into - # $defs. LinkML types are not part of $defs and are generated separately. - # all_req_schemas contains all directly or transitively required schemas from - # LinkML classes and types - # not_closed=True is deliberate: APIs are extended backwards-compatibly by - # adding attributes to existing objects, which additionalProperties=False - # blocks. Stated explicitly rather than inherited from the generator default, - # which follows the metamodel and closes classes with no `extra_slots`. - json_schema = JsonSchemaGenerator( - self.schemaview.schema, include_null=False, preserve_names=True, not_closed=True - ).generate() - all_req_schemas: dict[str, dict] = json.loads(json_schema.to_json())["$defs"] - for linkml_name in req_linkml_names: - if linkml_name in self.schemaview.all_types(): - all_req_schemas[linkml_name] = self._generate_type_schema(linkml_name) + all_req_schemas = self._generate_schemas(req_linkml_names) # sanitize schemas not transitively reachable from any endpoint-referenced schema sanitized_data_schemas = self._sanitize_schemas(name_map, all_req_schemas, req_linkml_names) @@ -500,7 +648,11 @@ def printout_template(self) -> str: first_element = next(iter(element_names)) if re.search(r"[ :\d]", first_element): first_element = f'"{first_element}"' - return openapi_generic_template.format(linkml_schema_id=self.schemaview.schema.id, data_schema=first_element) + return openapi_generic_template.format( + linkml_schema_id=self.schemaview.schema.id, + data_schema=first_element, + openapi_version_list=",".join(self._openapi_versions), + ) @shared_arguments(OpenApiGenerator) @@ -508,7 +660,7 @@ def printout_template(self) -> str: @click.option( "--template", "-t", - help="OpenAPI v3.0.3 template - includes the header, the endpoints and the security schemes", + help="OpenAPI template - includes the header, the endpoints and the security schemes", ) @click.option( "--keep-unreferenced", @@ -526,7 +678,7 @@ def printout_template(self) -> str: ) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, template, keep_unreferenced, inline_enums, **args): - """Generate an OpenAPI v3.0.3 spec with resources modelled with LinkML. + """Generate an OpenAPI YAML with resources modelled with LinkML. If no OpenAPI template is provided, a generic one with one exemplary class/type schema is printed out.""" # if no template provided, print out a generic one diff --git a/tests/linkml/test_generators/input/openapi/spec-head.openapi.yaml b/tests/linkml/test_generators/input/openapi/spec-head-v30.openapi.yaml similarity index 100% rename from tests/linkml/test_generators/input/openapi/spec-head.openapi.yaml rename to tests/linkml/test_generators/input/openapi/spec-head-v30.openapi.yaml diff --git a/tests/linkml/test_generators/input/openapi/spec-head-v31.openapi.yaml b/tests/linkml/test_generators/input/openapi/spec-head-v31.openapi.yaml new file mode 100644 index 0000000000..4846d45d71 --- /dev/null +++ b/tests/linkml/test_generators/input/openapi/spec-head-v31.openapi.yaml @@ -0,0 +1,58 @@ +openapi: 3.1.0 +info: + title: LinkML tests + version: 1.0.0 +servers: + - url: https://example.org/ +security: + - PayloadSignature: [] +paths: + /api/endpoint1: + post: + security: + - PayloadSignature: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MedicalEvent" + responses: + "200": + description: Success + get: + security: + - PayloadSignature: [] + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/MarriageEvent" + /api/endpoint2: + get: + security: + - PayloadSignature: [] + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + +components: + schemas: + MedicalEvent: + type: object + x-linkml-schema: https://w3id.org/linkml/tests/kitchen_sink + x-linkml-source: MedicalEvent + MarriageEvent: + type: object + x-linkml-schema: https://w3id.org/linkml/tests/kitchen_sink + x-linkml-source: MarriageEvent + Person: + type: object + x-linkml-schema: https://w3id.org/linkml/tests/kitchen_sink + x-linkml-source: Person diff --git a/tests/linkml/test_generators/test_openapigen.py b/tests/linkml/test_generators/test_openapigen.py index 9642a7447d..ad76c091e5 100644 --- a/tests/linkml/test_generators/test_openapigen.py +++ b/tests/linkml/test_generators/test_openapigen.py @@ -3,7 +3,7 @@ import pytest import yaml -from openapi_spec_validator import OpenAPIV30SpecValidator, validate +from openapi_spec_validator import OpenAPIV30SpecValidator, OpenAPIV31SpecValidator, validate from referencing.exceptions import PointerToNowhere from linkml.generators.openapigen import OpenApiGenerator @@ -14,6 +14,19 @@ # Reusable YAML fragments # --------------------------------------------------------------------------- +# OpenAPI versions the test-suite is driven with, mapped to the validator class from +# openapi-spec-validator used to check the generated specs. The generator selects the +# generation path from the template's top-level ``openapi`` attribute, so a version is +# exercised simply by advertising it in the template. Extend this dict together with +# the generator when a new OpenAPI version becomes supported. +OAS_VALIDATORS: dict[str, type] = { + "3.0.3": OpenAPIV30SpecValidator, + "3.1.0": OpenAPIV31SpecValidator, +} + +# Default OpenAPI version used by templates/tests that are not version-parametrized. +DEFAULT_OAS_VERSION = "3.0.3" + # LinkML schema document preamble shared by the inline enum/chain test schemas. LINKML_HEADER = """\ id: https://w3id.org/linkml/tests/{name} @@ -25,7 +38,7 @@ # OpenAPI template header (title + quoted version) shared by most small templates. OPENAPI_HEADER = """\ -openapi: 3.0.3 +openapi: {oas_version} info: title: {title} version: '1.0.0' @@ -202,6 +215,7 @@ def openapi_template( header: str = "", comment: str = "", components: str = "", + oas_version: str = DEFAULT_OAS_VERSION, ) -> str: """Compose an OpenAPI template from the shared header, endpoints and schemas. @@ -211,8 +225,9 @@ def openapi_template( :param header: optional extra header block (e.g. servers/security) :param comment: optional leading comment line(s) :param components: extra ``components`` sections before ``schemas`` (e.g. responses) + :param oas_version: the OpenAPI version the template advertises """ - doc = dedent(OPENAPI_HEADER).format(title=title) + doc = dedent(OPENAPI_HEADER).format(title=title, oas_version=oas_version) if header: doc += dedent(header) + "\n" doc += "paths:\n" @@ -238,6 +253,7 @@ def single_endpoint_template( header: str = "", comment: str = "", secure: bool = False, + oas_version: str = DEFAULT_OAS_VERSION, ) -> str: """Compose a template with one GET endpoint referencing one generated schema. @@ -250,10 +266,13 @@ def single_endpoint_template( :param header: optional extra header block (e.g. servers/security) :param comment: optional leading comment line(s) :param secure: add per-endpoint ``security`` + :param oas_version: the OpenAPI version the template advertises """ endpoint = get_endpoint(path_name, schema_name, description=description, secure=secure) schemas = schema_stub(schema_name, schema_id, source) - return openapi_template(title, endpoints=endpoint, schemas=schemas, header=header, comment=comment) + return openapi_template( + title, endpoints=endpoint, schemas=schemas, header=header, comment=comment, oas_version=oas_version + ) # --------------------------------------------------------------------------- @@ -320,177 +339,265 @@ def single_endpoint_template( # Inline OpenAPI templates # --------------------------------------------------------------------------- -TEMPLATE_ENDPOINT_ENUM = single_endpoint_template( - "Endpoint Enum Test", - "/fixed-enum", - "FixedEnum", - schema_id=ENDPOINT_ENUM_ID, - source="FixedEnum", - description="ok", -) -TEMPLATE_ENUM_SLOT_DESCRIPTION = single_endpoint_template( - "Enum Slot Description Test", - "/foo", - "Foo", - schema_id=ENUM_SLOT_DESCRIPTION_ID, - source="Foo", - description="ok", -) +def template_endpoint_enum(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose endpoint references an enum defined in the LinkML schema. -TEMPLATE_EXAMPLES = single_endpoint_template( - "LinkML examples test", - "/api/with-examples", - "WithExamples", - schema_id=TYPES_AND_ENUMS_ID, - source="WithExamples", - header=TEMPLATE_SERVERS_SECURITY, - comment="# OpenAPI template referring a class whose slot declares multiple examples", -) + :param oas_version: the OpenAPI version the template advertises + """ + return single_endpoint_template( + "Endpoint Enum Test", + "/fixed-enum", + "FixedEnum", + schema_id=ENDPOINT_ENUM_ID, + source="FixedEnum", + description="ok", + oas_version=oas_version, + ) -TEMPLATE_FIXED = openapi_template( - "LinkML tests", - endpoints=POST_FIXED, - schemas="", - header=TEMPLATE_SERVERS_SECURITY, - comment="# OpenAPI template provided as template that is fully fixed\n# because there are no fields to be replaced", -) -TEMPLATE_KEEP_SCOPED = single_endpoint_template( - "Keep Unreferenced Scoped Test", - "/api/foo", - "Foo", - schema_id=UNREFERENCED_WITH_UNRELATED_ID, - source="Foo", -) +def template_enum_slot_description(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose referenced class has a slot-level enum description. -TEMPLATE_LOWERCASE_CLASS = single_endpoint_template( - "LinkML tests", - "/api/dataset", - "Dataset", - schema_id=KITCHEN_SINK_ID, - source="Dataset", -) + :param oas_version: the OpenAPI version the template advertises + """ + return single_endpoint_template( + "Enum Slot Description Test", + "/foo", + "Foo", + schema_id=ENUM_SLOT_DESCRIPTION_ID, + source="Foo", + description="ok", + oas_version=oas_version, + ) -TEMPLATE_MISSING_XLINKML_SOURCE = """\ -openapi: 3.0.3 -info: {title: Foo API, version: "1.0"} -paths: - /foo: - get: - responses: - '200': - description: ok - content: - application/json: - schema: {$ref: '#/components/schemas/Foo'} -components: - schemas: -""" + "".join( - [ - schema_stub("Foo", FOO_ID, "Foo"), - """\ + +def template_examples(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose referenced class declares multiple slot examples. + + :param oas_version: the OpenAPI version the template advertises + """ + return single_endpoint_template( + "LinkML examples test", + "/api/with-examples", + "WithExamples", + schema_id=TYPES_AND_ENUMS_ID, + source="WithExamples", + header=TEMPLATE_SERVERS_SECURITY, + comment="# OpenAPI template referring a class whose slot declares multiple examples", + oas_version=oas_version, + ) + + +def template_fixed(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a fully fixed template with no replaceable fields. + + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "LinkML tests", + endpoints=POST_FIXED, + schemas="", + header=TEMPLATE_SERVERS_SECURITY, + comment="# OpenAPI template provided as template that is fully fixed\n" + + "# because there are no fields to be replaced", + oas_version=oas_version, + ) + + +def template_keep_scoped(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose single schema keeps ``keep_unreferenced`` scoped. + + :param oas_version: the OpenAPI version the template advertises + """ + return single_endpoint_template( + "Keep Unreferenced Scoped Test", + "/api/foo", + "Foo", + schema_id=UNREFERENCED_WITH_UNRELATED_ID, + source="Foo", + oas_version=oas_version, + ) + + +def template_lowercase_class(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose endpoint references a lowercase-named LinkML class. + + :param oas_version: the OpenAPI version the template advertises + """ + return single_endpoint_template( + "LinkML tests", + "/api/dataset", + "Dataset", + schema_id=KITCHEN_SINK_ID, + source="Dataset", + oas_version=oas_version, + ) + + +def template_renamed_type(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template exposing a LinkML type under a different OpenAPI resource name. + + :param oas_version: the OpenAPI version the template advertises + """ + return single_endpoint_template( + "Renamed Type Test", + "/fixed", + "Fixed", + schema_id=TYPES_AND_ENUMS_ID, + source="FixedType", + description="ok", + oas_version=oas_version, + ) + + +def template_renaming(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template exposing a LinkML class under a different OpenAPI resource name.""" + return single_endpoint_template( + "LinkML tests - renaming", + "/api/persons", + "PersonResource", + schema_id=KITCHEN_SINK_ID, + source="Person", + header=TEMPLATE_SERVERS_SECURITY, + oas_version=oas_version, + ) + + +def template_missing_xlinkml_source(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose second schema stub omits the ``x-linkml-source`` key. + + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "Foo API", + endpoints=get_endpoint("/foo", "Foo", description="ok"), + schemas="" + + "".join( + [ + schema_stub("Foo", FOO_ID, "Foo"), + """\ Bar: type: object x-linkml-schema: https://example.org/foo """, - ] -) + ] + ), + oas_version=oas_version, + ) -TEMPLATE_RENAMED_TYPE = single_endpoint_template( - "Renamed Type Test", - "/fixed", - "Fixed", - schema_id=TYPES_AND_ENUMS_ID, - source="FixedType", - description="ok", -) -TEMPLATE_RENAMING = single_endpoint_template( - "LinkML tests - renaming", - "/api/persons", - "PersonResource", - schema_id=KITCHEN_SINK_ID, - source="Person", - header=TEMPLATE_SERVERS_SECURITY, -) +def template_shared_responses(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template referencing a reusable ``components/responses`` entry. -TEMPLATE_SHARED_RESPONSES = openapi_template( - "Shared Responses Test", - endpoints=get_endpoint( - "/foo", - "Person", - description="ok", - responses=""" '404': + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "Shared Responses Test", + endpoints=get_endpoint( + "/foo", + "Person", + description="ok", + responses=""" '404': $ref: '#/components/responses/NotFound' """, - ), - schemas=schema_stub("Person", KITCHEN_SINK_ID, "Person"), - components=""" responses: + ), + schemas=schema_stub("Person", KITCHEN_SINK_ID, "Person"), + components=""" responses: NotFound: description: not found """, -) + oas_version=oas_version, + ) -TEMPLATE_REFERENCED_PARAMETER = openapi_template( - "t", - endpoints=get_endpoint( - "/foo", - "Foo", - description="ok", - extra=""" parameters: + +def template_referenced_parameter(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose endpoint parameter is given as a ``$ref``. + + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "t", + endpoints=get_endpoint( + "/foo", + "Foo", + description="ok", + extra=""" parameters: - $ref: '#/components/parameters/Limit' """, - ), - schemas=schema_stub("Foo", FOO_ID, "Foo"), - components=""" parameters: + ), + schemas=schema_stub("Foo", FOO_ID, "Foo"), + components=""" parameters: Limit: name: limit in: query schema: type: integer """, -) + oas_version=oas_version, + ) -TEMPLATE_TYPES = single_endpoint_template( - "LinkML type constraints test", - "/api/code", - "CodeStringRef", - schema_id=TYPES_AND_ENUMS_ID, - source="CodeString", - comment="# OpenAPI template referring a Type defined in the LinkML schema", -) -TEMPLATE_TYPES_ENUMS = single_endpoint_template( - "Types and Enums Test", - "/api/fixed", - "FixedType", - schema_id=TYPES_AND_ENUMS_ID, - source="FixedType", - header=TEMPLATE_SERVERS_SECURITY, -) +def template_types(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose endpoint references a LinkML Type (constraints inlined).""" + return single_endpoint_template( + "LinkML type constraints test", + "/api/code", + "CodeStringRef", + schema_id=TYPES_AND_ENUMS_ID, + source="CodeString", + comment="# OpenAPI template referring a Type defined in the LinkML schema", + oas_version=oas_version, + ) -TEMPLATE_WRONG_SCHEMA_ID = single_endpoint_template( - "LinkML tests - wrong schema id", - "/api/endpoint1", - "Person", - schema_id=WRONG_SCHEMA_ID, - source="Person", - header=TEMPLATE_SERVERS_SECURITY, -) -TEMPLATE_COMMENTS = openapi_template( - "Comment Preservation Test", - endpoints=get_endpoint( - "/api/person", +def template_types_enums(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose endpoint references a LinkML type. + + :param oas_version: the OpenAPI version the template advertises + """ + return single_endpoint_template( + "Types and Enums Test", + "/api/fixed", + "FixedType", + schema_id=TYPES_AND_ENUMS_ID, + source="FixedType", + header=TEMPLATE_SERVERS_SECURITY, + oas_version=oas_version, + ) + + +def template_wrong_schema_id(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose schema declares a mismatched ``x-linkml-schema`` id.""" + return single_endpoint_template( + "LinkML tests - wrong schema id", + "/api/endpoint1", "Person", - secure=True, - comment="# this endpoint comment must survive", - ), - schemas=schema_stub("Person", KITCHEN_SINK_ID, "Person"), - header=TEMPLATE_SERVERS_SECURITY, - comment="# top-level comment must survive round-trip", -) + schema_id=WRONG_SCHEMA_ID, + source="Person", + header=TEMPLATE_SERVERS_SECURITY, + oas_version=oas_version, + ) + + +def template_comments(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template carrying comments on the header and an endpoint. + + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "Comment Preservation Test", + endpoints=get_endpoint( + "/api/person", + "Person", + secure=True, + comment="# this endpoint comment must survive", + ), + schemas=schema_stub("Person", KITCHEN_SINK_ID, "Person"), + header=TEMPLATE_SERVERS_SECURITY, + comment="# top-level comment must survive round-trip", + oas_version=oas_version, + ) # --------------------------------------------------------------------------- @@ -510,72 +617,107 @@ def single_endpoint_template( header=TEMPLATE_SERVERS_SECURITY, ) -TEMPLATE_KEEP_UNREFERENCED = openapi_template( - "Keep Unreferenced Test", - endpoints=get_endpoint("/api/person", "Person", secure=True), - schemas=schema_stubs( - [ - ("Person", KITCHEN_SINK_ID, "Person"), - ("OpaqueEvent", KITCHEN_SINK_ID, "MarriageEvent"), - ] - ), - header=TEMPLATE_SERVERS_SECURITY, -) -TEMPLATE_SHARED_ENUM = openapi_template( - "Shared Enum Test", - endpoints=get_endpoint("/foo", "Foo", description="ok") + get_endpoint("/bar", "Bar", description="ok"), - schemas=schema_stubs( - [ - ("Foo", SHARED_ENUM_ID, "Foo"), - ("Bar", SHARED_ENUM_ID, "Bar"), - ] - ), -) +def template_keep_unreferenced(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose second schema is not referenced by any endpoint. -TEMPLATE_DANGLING_REF = openapi_template( - "Dangling Reference Test", - endpoints=get_endpoint("/api/person", "Person", secure=True) + get_endpoint("/api/foo", "Foo", secure=True), - schemas=schema_stubs( - [ - ("Person", KITCHEN_SINK_ID, "Person"), - ("Foo", KITCHEN_SINK_ID, "NonExistentClass"), - ] - ), - header=TEMPLATE_SERVERS_SECURITY, -) + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "Keep Unreferenced Test", + endpoints=get_endpoint("/api/person", "Person", secure=True), + schemas=schema_stubs( + [ + ("Person", KITCHEN_SINK_ID, "Person"), + ("OpaqueEvent", KITCHEN_SINK_ID, "MarriageEvent"), + ] + ), + header=TEMPLATE_SERVERS_SECURITY, + oas_version=oas_version, + ) -TEMPLATE_DANGLING_REFS_MULTIPLE = openapi_template( - "Multiple Dangling References Test", - endpoints=( - get_endpoint("/api/person", "Person", secure=True) - + get_endpoint("/api/foo", "Foo", secure=True) - + get_endpoint("/api/bar", "Bar", secure=True) - ), - schemas=schema_stubs( - [ - ("Person", KITCHEN_SINK_ID, "Person"), - ("Foo", KITCHEN_SINK_ID, "NonExistentClassFoo"), - ("Bar", KITCHEN_SINK_ID, "NonExistentClassBar"), - ] - ), - header=TEMPLATE_SERVERS_SECURITY, -) -TEMPLATE_HEAD = openapi_template( - "LinkML tests", - endpoints=POST_MEDICAL_EVENT - + get_endpoint("/api/person", "Person", secure=True) - + get_endpoint("/api/endpoint2", "MarriageEvent", secure=True), - schemas=schema_stubs( - [ - ("MedicalEvent", KITCHEN_SINK_ID, "MedicalEvent"), - ("Person", KITCHEN_SINK_ID, "Person"), - ("MarriageEvent", KITCHEN_SINK_ID, "MarriageEvent"), - ] - ), - header=TEMPLATE_SERVERS_SECURITY, -) +def template_shared_enum(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template whose two endpoints reference classes sharing an enum. + + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "Shared Enum Test", + endpoints=get_endpoint("/foo", "Foo", description="ok") + get_endpoint("/bar", "Bar", description="ok"), + schemas=schema_stubs( + [ + ("Foo", SHARED_ENUM_ID, "Foo"), + ("Bar", SHARED_ENUM_ID, "Bar"), + ] + ), + oas_version=oas_version, + ) + + +def template_dangling_ref(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template with one schema sourced from a non-existent LinkML class. + + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "Dangling Reference Test", + endpoints=get_endpoint("/api/person", "Person", secure=True) + get_endpoint("/api/foo", "Foo", secure=True), + schemas=schema_stubs( + [ + ("Person", KITCHEN_SINK_ID, "Person"), + ("Foo", KITCHEN_SINK_ID, "NonExistentClass"), + ] + ), + header=TEMPLATE_SERVERS_SECURITY, + oas_version=oas_version, + ) + + +def template_dangling_refs_multiple(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose a template with several schemas sourced from non-existent LinkML classes. + + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "Multiple Dangling References Test", + endpoints=( + get_endpoint("/api/person", "Person", secure=True) + + get_endpoint("/api/foo", "Foo", secure=True) + + get_endpoint("/api/bar", "Bar", secure=True) + ), + schemas=schema_stubs( + [ + ("Person", KITCHEN_SINK_ID, "Person"), + ("Foo", KITCHEN_SINK_ID, "NonExistentClassFoo"), + ("Bar", KITCHEN_SINK_ID, "NonExistentClassBar"), + ] + ), + header=TEMPLATE_SERVERS_SECURITY, + oas_version=oas_version, + ) + + +def template_head(oas_version: str = DEFAULT_OAS_VERSION) -> str: + """Compose the kitchen_sink template with one post and two get endpoints. + + :param oas_version: the OpenAPI version the template advertises + """ + return openapi_template( + "LinkML tests", + endpoints=POST_MEDICAL_EVENT + + get_endpoint("/api/person", "Person", secure=True) + + get_endpoint("/api/endpoint2", "MarriageEvent", secure=True), + schemas=schema_stubs( + [ + ("MedicalEvent", KITCHEN_SINK_ID, "MedicalEvent"), + ("Person", KITCHEN_SINK_ID, "Person"), + ("MarriageEvent", KITCHEN_SINK_ID, "MarriageEvent"), + ] + ), + header=TEMPLATE_SERVERS_SECURITY, + oas_version=oas_version, + ) # --------------------------------------------------------------------------- @@ -600,21 +742,40 @@ def gen_openapi_spec(head_path, kitchen_sink_path): return openapigen.serialize(head_path) +def assert_fixed_value(schema: dict, expected, oas_version: str) -> None: + """Assert a fixed-value schema exposes ``expected`` via the const/enum keyword of its version. + + The v3.0.3 JsonSchema path emits a fixed LinkML type as a single-item ``enum`` while + the v3.1.0 Pydantic path keeps JSON Schema's native ``const``. + """ + if oas_version == "3.0.3": + assert schema["enum"] == [expected] + assert "const" not in schema + else: + assert schema["const"] == expected + assert "enum" not in schema + + +@pytest.fixture(params=list(OAS_VALIDATORS)) +def oas_version(request): + """The OpenAPI version under test; drives the version-parametrized fixtures/tests.""" + return request.param + + @pytest.fixture -def openapi_spec(tmp_path, kitchen_sink_path): - head_path = write_template(tmp_path, TEMPLATE_HEAD) - openapigen = OpenApiGenerator(kitchen_sink_path) - return yaml.safe_load(openapigen.serialize(head_path)) +def openapi_spec(tmp_path, kitchen_sink_path, oas_version): + head_path = write_template(tmp_path, template_head(oas_version=oas_version)) + return yaml.safe_load(gen_openapi_spec(head_path, kitchen_sink_path)) -def test_openapi(tmp_path, kitchen_sink_path): +def test_openapi(tmp_path, kitchen_sink_path, oas_version): """Test if generation succeeds without failure and returns valid YAML.""" - head_path = write_template(tmp_path, TEMPLATE_HEAD) + head_path = write_template(tmp_path, template_head(oas_version=oas_version)) openapi_spec = gen_openapi_spec(head_path, kitchen_sink_path) # ensure that valid YAML has been generated assert yaml.safe_load(openapi_spec) # ensure that valid OpenAPI spec has been generated - assert validate(yaml.safe_load(openapi_spec), cls=OpenAPIV30SpecValidator) is None + assert validate(yaml.safe_load(openapi_spec), cls=OAS_VALIDATORS[oas_version]) is None def test_openapi_missing_template(kitchen_sink_path): @@ -623,9 +784,9 @@ def test_openapi_missing_template(kitchen_sink_path): OpenApiGenerator(kitchen_sink_path).serialize() -def test_openapi_fixed_template(tmp_path, kitchen_sink_path): +def test_openapi_fixed_template(tmp_path, kitchen_sink_path, oas_version): """Test that a template with no replaceable fields is emitted byte-for-byte.""" - head_path = write_template(tmp_path, TEMPLATE_FIXED) + head_path = write_template(tmp_path, template_fixed(oas_version=oas_version)) oa_spec = OpenApiGenerator(kitchen_sink_path).serialize(head_path) assert Path(head_path).read_text() == oa_spec @@ -636,13 +797,29 @@ def test_openapi_spec_no_defs_references(openapi_spec): assert "#/$defs/" not in str(schema) -def test_openapi_spec_const_to_enum_conversion(openapi_spec): - """Test that const values are converted to single-item enum arrays.""" +def test_openapi_spec_const_conversion(openapi_spec, oas_version): + """Test const handling per version: enum arrays on 3.0.3, preserved const on 3.1.0.""" person = openapi_spec["components"]["schemas"]["Person"] - assert person["properties"]["species_name"]["enum"] == ["human"] - assert person["properties"]["stomach_count"]["enum"] == [1] - assert "const" not in person["properties"]["species_name"] - assert "const" not in person["properties"]["stomach_count"] + species_name = person["properties"]["species_name"] + stomach_count = person["properties"]["stomach_count"] + if oas_version == "3.0.3": + # OpenAPI 3.0 has no ``const``; the generator rewrites it to a single-item ``enum`` + assert species_name["enum"] == ["human"] + assert stomach_count["enum"] == [1] + assert "const" not in species_name + assert "const" not in stomach_count + else: + # OpenAPI 3.1 is aligned with JSON Schema 2020-12, so ``const`` is kept as-is + assert "const" in str(species_name) + assert "const" in str(stomach_count) + + +def test_openapi_v31_no_linkml_meta(tmp_path, kitchen_sink_path): + """Test that the v3.1.0 Pydantic path strips ``linkml_meta`` annotations from schemas.""" + head_path = write_template(tmp_path, template_head(oas_version="3.1.0")) + spec = yaml.safe_load(gen_openapi_spec(head_path, kitchen_sink_path)) + for schema in spec["components"]["schemas"].values(): + assert "linkml_meta" not in str(schema) def test_openapi_spec_class_level_title_stripped(openapi_spec): @@ -652,13 +829,17 @@ def test_openapi_spec_class_level_title_stripped(openapi_spec): assert person["properties"]["age_in_years"]["description"] == "number of years since birth" -def test_openapi_spec_nullable_type_conversion(openapi_spec): - """Test that nullable type arrays are converted to anyOf.""" +def test_openapi_spec_nullable_type_conversion(openapi_spec, oas_version): + """Test nullable handling per version: anyOf on 3.0.3, native type arrays on 3.1.0.""" emp_event = openapi_spec["components"]["schemas"]["EmploymentEvent"] - assert "anyOf" in emp_event["properties"]["type"] - assert "type" not in emp_event["properties"]["type"] or not isinstance( - emp_event["properties"]["type"]["type"], list - ) + type_prop = emp_event["properties"]["type"] + if oas_version == "3.0.3": + # OpenAPI 3.0 forbids type arrays; nullable ``["x", "null"]`` becomes ``anyOf`` + assert "anyOf" in type_prop + assert "type" not in type_prop or not isinstance(type_prop["type"], list) + else: + # OpenAPI 3.1 permits nullable type arrays and ``anyOf`` alike; either is valid + assert "anyOf" in type_prop or isinstance(type_prop.get("type"), list) def test_openapi_spec_schemas_are_extensible(openapi_spec): @@ -666,12 +847,23 @@ def test_openapi_spec_schemas_are_extensible(openapi_spec): APIs are typically extended backwards-compatibly by adding new objects or new attributes to existing objects. Closed schemas (additionalProperties: false) block - that, so the generated OpenAPI schemas must stay open. + that, so the generated OpenAPI schemas must stay open -- at every nesting level, + including inlined sub-schemas (relevant for the v3.1.0 Pydantic path, which must be + driven with ``extra_fields="allow"``). """ - for name, schema in openapi_spec["components"]["schemas"].items(): - assert schema.get("additionalProperties") is not False, ( - f"schema '{name}' is closed (additionalProperties: false), blocking API extension" - ) + + def _closed_paths(obj, path=""): + if isinstance(obj, dict): + if obj.get("additionalProperties") is False: + yield path or "" + for key, value in obj.items(): + yield from _closed_paths(value, f"{path}/{key}") + elif isinstance(obj, list): + for i, item in enumerate(obj): + yield from _closed_paths(item, f"{path}[{i}]") + + closed = list(_closed_paths(openapi_spec["components"]["schemas"])) + assert not closed, f"closed schemas (additionalProperties: false) block API extension: {closed}" def test_resources_presence_and_absence(openapi_spec): @@ -688,33 +880,33 @@ def test_printout_template(kitchen_sink_path): """Test that printout_template returns a valid YAML generic template.""" output = OpenApiGenerator(kitchen_sink_path).printout_template() parsed = yaml.safe_load(output) - assert parsed["openapi"] == "3.0.3" + assert parsed["openapi"] == "x.y.z" assert "paths" in parsed assert "schemas" in parsed["components"] # the schema id from kitchen_sink must appear in the template assert "https://w3id.org/linkml/tests/kitchen_sink" in output -def test_schema_id_mismatch_raises(tmp_path, kitchen_sink_path): +def test_schema_id_mismatch_raises(tmp_path, kitchen_sink_path, oas_version): """Test that a mismatched x-linkml-schema raises ValueError with a descriptive message.""" - head_path = write_template(tmp_path, TEMPLATE_WRONG_SCHEMA_ID) + head_path = write_template(tmp_path, template_wrong_schema_id(oas_version=oas_version)) with pytest.raises(ValueError, match="x-linkml-schema"): OpenApiGenerator(kitchen_sink_path).serialize(head_path) -def test_missing_x_linkml_source_raises(input_path, tmp_path): +def test_missing_x_linkml_source_raises(input_path, tmp_path, oas_version): """Test that a template schema missing x-linkml-source raises a descriptive KeyError. x-linkml-schema presence/value are validated nicely, but x-linkml-source was skipped, surfacing as a bare ``KeyError: 'x-linkml-source'`` during instantiation. """ schema_path = input_path("openapi/schema_foo.yaml") - head_path = write_template(tmp_path, TEMPLATE_MISSING_XLINKML_SOURCE) + head_path = write_template(tmp_path, template_missing_xlinkml_source(oas_version=oas_version)) with pytest.raises(KeyError, match="Bar.*missing required 'x-linkml-source'"): OpenApiGenerator(schema_path, keep_unreferenced=True).serialize(head_path) -def test_referenced_parameter_does_not_crash(input_path, tmp_path): +def test_referenced_parameter_does_not_crash(input_path, tmp_path, oas_version): """Test that a template parameter given as a $ref does not raise KeyError. A parameter entry of the form ``{$ref: '#/components/parameters/Limit'}`` has no @@ -722,13 +914,13 @@ def test_referenced_parameter_does_not_crash(input_path, tmp_path): reading ``param_spec["schema"]`` unconditionally crashed before generation. """ schema_path = input_path("openapi/schema_foo.yaml") - head_path = write_template(tmp_path, TEMPLATE_REFERENCED_PARAMETER) + head_path = write_template(tmp_path, template_referenced_parameter(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(schema_path).serialize(head_path)) # the reusable parameter survives untouched assert spec["paths"]["/foo"]["get"]["parameters"] == [{"$ref": "#/components/parameters/Limit"}] # and the endpoint schema is generated as usual assert "Foo" in spec["components"]["schemas"] - assert validate(spec, cls=OpenAPIV30SpecValidator) is None + assert validate(spec, cls=OAS_VALIDATORS[oas_version]) is None def test_missing_schema_declaration_raises(tmp_path, kitchen_sink_path): @@ -752,25 +944,30 @@ def test_missing_schema_declaration_raises(tmp_path, kitchen_sink_path): OpenApiGenerator(kitchen_sink_path).serialize(str(template)) -def test_openapi_type_constraints(input_path, tmp_path): - """Test that LinkML types with constraints (e.g., pattern) are properly generated in the spec.""" +def test_openapi_type_constraints(input_path, tmp_path, oas_version): + """Test that a LinkML type (constraints inlined) still yields a standalone component schema. + + On both the v3.0.3 (JsonSchema) and v3.1.0 (Pydantic) paths, LinkML types are not + emitted as classes; an endpoint referencing a type directly (via x-linkml-source) + must still produce a component schema, otherwise the spec has a dangling ``$ref``. + """ schema_path = input_path("openapi/schema_types_and_enums.yaml") - head_path = write_template(tmp_path, TEMPLATE_TYPES) + head_path = write_template(tmp_path, template_types(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(schema_path).serialize(head_path)) schemas = spec["components"]["schemas"] - # the type schema is exposed under the template's resource name + # the type schema is exposed under the template's resource name, not dangling code_str = schemas["CodeStringRef"] assert code_str["type"] == "string" assert code_str["pattern"] == "^[A-Z]{2,10}$" assert code_str["description"] == "A 2-10 character uppercase code" - assert validate(spec, cls=OpenAPIV30SpecValidator) is None + assert validate(spec, cls=OAS_VALIDATORS[oas_version]) is None for schema in schemas.values(): assert "#/$defs/" not in str(schema) -def test_renaming(tmp_path, kitchen_sink_path): +def test_renaming(tmp_path, kitchen_sink_path, oas_version): """Test that resource names differing from LinkML class names are renamed throughout the spec.""" - head_path = write_template(tmp_path, TEMPLATE_RENAMING) + head_path = write_template(tmp_path, template_renaming(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(kitchen_sink_path).serialize(head_path)) schemas = spec["components"]["schemas"] # resource is exposed under the template name, not the LinkML class name @@ -788,7 +985,7 @@ def test_openapi_examples_converted_to_singular_example(input_path, tmp_path): slot declaring ``examples`` -- this is a blocker, not a cosmetic gap. """ schema_path = input_path("openapi/schema_types_and_enums.yaml") - head_path = write_template(tmp_path, TEMPLATE_EXAMPLES) + head_path = write_template(tmp_path, template_examples()) spec = yaml.safe_load(OpenApiGenerator(schema_path).serialize(head_path)) name_schema = spec["components"]["schemas"]["WithExamples"]["properties"]["name"] # first example is kept; the plural form is gone entirely @@ -797,14 +994,14 @@ def test_openapi_examples_converted_to_singular_example(input_path, tmp_path): assert validate(spec, cls=OpenAPIV30SpecValidator) is None -def test_template_text_preserved(tmp_path, kitchen_sink_path): +def test_template_text_preserved(tmp_path, kitchen_sink_path, oas_version): """Test that everything above ``components/schemas`` is emitted verbatim. The generator no longer YAML round-trips the whole template (which would drop comments and normalise quoting/styling). Only the ``components/schemas`` section is regenerated; the header, paths and any comments above it must survive intact. """ - head_path = write_template(tmp_path, TEMPLATE_COMMENTS) + head_path = write_template(tmp_path, template_comments(oas_version=oas_version)) result = OpenApiGenerator(kitchen_sink_path).serialize(head_path) # comments are dropped by a YAML round-trip but preserved by text handling assert "# top-level comment must survive round-trip" in result @@ -818,9 +1015,9 @@ def test_template_text_preserved(tmp_path, kitchen_sink_path): assert result.startswith(prefix) -def test_unreferenced_schema_removed_by_default(tmp_path, kitchen_sink_path): +def test_unreferenced_schema_removed_by_default(tmp_path, kitchen_sink_path, oas_version): """Test that template schemas not referenced by any endpoint are removed by default.""" - head_path = write_template(tmp_path, TEMPLATE_KEEP_UNREFERENCED) + head_path = write_template(tmp_path, template_keep_unreferenced(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(kitchen_sink_path).serialize(head_path)) schemas = spec["components"]["schemas"] assert "Person" in schemas @@ -828,19 +1025,21 @@ def test_unreferenced_schema_removed_by_default(tmp_path, kitchen_sink_path): assert "MarriageEvent" not in schemas -def test_keep_unreferenced_preserves_template_schema(tmp_path, kitchen_sink_path): +def test_keep_unreferenced_preserves_template_schema(tmp_path, kitchen_sink_path, oas_version): """Test that keep_unreferenced retains template schemas not referenced by any endpoint. Unreferenced sub-schemas can convey objects that are opaque to the API but relevant to clients (e.g. present in provided artifacts). The keep_unreferenced flag makes their removal switchable. """ - head_path = write_template(tmp_path, TEMPLATE_KEEP_UNREFERENCED) + head_path = write_template(tmp_path, template_keep_unreferenced(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(kitchen_sink_path, keep_unreferenced=True).serialize(head_path)) schemas = spec["components"]["schemas"] assert "Person" in schemas - # OpaqueEvent(OpenAPI)/MarriageEvent(LinkML) is kept even though no endpoint references it - assert "MarriageEvent" in schemas + # OpaqueEvent(OpenAPI)/MarriageEvent(LinkML) is kept even though no endpoint references it, + # and is exposed under its OpenAPI resource name, not the LinkML class name + assert "OpaqueEvent" in schemas + assert "MarriageEvent" not in schemas def test_unreferenced_chain_pruned_by_default(tmp_path): @@ -880,7 +1079,7 @@ def test_keep_unreferenced_pulls_transitive_chain(tmp_path): assert "Baz Qux" in schemas -def test_keep_unreferenced_does_not_add_unrelated_schemas(tmp_path): +def test_keep_unreferenced_does_not_add_unrelated_schemas(tmp_path, oas_version): """Test that keep_unreferenced stays scoped to the template, not "dump everything". The chain schema also contains classes not reachable from ``Foo`` or the template @@ -891,7 +1090,7 @@ def test_keep_unreferenced_does_not_add_unrelated_schemas(tmp_path): every class. """ schema_path = load_schema(SCHEMA_UNREFERENCED_WITH_UNRELATED) - head_path = write_template(tmp_path, TEMPLATE_KEEP_SCOPED) + head_path = write_template(tmp_path, template_keep_scoped(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(schema_path, keep_unreferenced=True).serialize(head_path)) schemas = spec["components"]["schemas"] assert "Foo" in schemas @@ -911,13 +1110,13 @@ def test_enums_as_separate_schemas_by_default(openapi_spec): assert {"$ref": "#/components/schemas/EmploymentEventType"} in type_schema["anyOf"] -def test_inline_enums_inlines_enum_schemas(tmp_path, kitchen_sink_path): +def test_inline_enums_inlines_enum_schemas(tmp_path, kitchen_sink_path, oas_version): """Test that inline_enums inlines enum sub-schemas into their parents. With the flag set, an enum no longer gets its own ``components/schemas`` entry; instead its definition is inlined where it was referenced. """ - head_path = write_template(tmp_path, TEMPLATE_HEAD) + head_path = write_template(tmp_path, template_head(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(kitchen_sink_path, inline_enums=True).serialize(head_path)) schemas = spec["components"]["schemas"] # the enum no longer has a standalone schema entry @@ -930,7 +1129,7 @@ def test_inline_enums_inlines_enum_schemas(tmp_path, kitchen_sink_path): assert "EmploymentEventType" not in str(spec) -def test_inline_enums_does_not_inline_types(input_path, tmp_path): +def test_inline_enums_does_not_inline_types(input_path, tmp_path, oas_version): """Test that inline_enums does not mistake fixed-value LinkML types for enums. A type with ``equals_string`` becomes a single-element ``enum`` after the @@ -939,27 +1138,27 @@ def test_inline_enums_does_not_inline_types(input_path, tmp_path): types must keep their named schema entry even when inlining is enabled. """ schema_path = input_path("openapi/schema_types_and_enums.yaml") - head_path = write_template(tmp_path, TEMPLATE_TYPES_ENUMS) + head_path = write_template(tmp_path, template_types_enums(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(schema_path, inline_enums=True).serialize(head_path)) schemas = spec["components"]["schemas"] # the fixed-value type keeps its own named schema entry (not inlined) assert "FixedType" in schemas - assert schemas["FixedType"]["enum"] == ["fixed-value"] + assert_fixed_value(schemas["FixedType"], "fixed-value", oas_version) assert schemas["FixedType"]["type"] == "string" -def test_inline_enums_disabled_keeps_types_and_enums_separate(input_path, tmp_path): +def test_inline_enums_disabled_keeps_types_and_enums_separate(input_path, tmp_path, oas_version): """Test that with inline_enums disabled both types and enums keep separate schema entries.""" schema_path = input_path("openapi/schema_types_and_enums.yaml") - head_path = write_template(tmp_path, TEMPLATE_TYPES_ENUMS) + head_path = write_template(tmp_path, template_types_enums(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(schema_path, inline_enums=False).serialize(head_path)) schemas = spec["components"]["schemas"] assert "FixedType" in schemas - assert schemas["FixedType"]["enum"] == ["fixed-value"] + assert_fixed_value(schemas["FixedType"], "fixed-value", oas_version) assert schemas["FixedType"]["type"] == "string" -def test_inline_enums_keeps_endpoint_referenced_enum(tmp_path): +def test_inline_enums_keeps_endpoint_referenced_enum(tmp_path, oas_version): """Test that inline_enums does not inline an enum referenced directly by an endpoint. Inlining removes the enum's standalone ``components/schemas`` entry, which would @@ -967,7 +1166,7 @@ def test_inline_enums_keeps_endpoint_referenced_enum(tmp_path): endpoint must therefore keep its entry even when inlining is enabled. """ schema_path = load_schema(SCHEMA_ENDPOINT_ENUM) - head_path = write_template(tmp_path, TEMPLATE_ENDPOINT_ENUM) + head_path = write_template(tmp_path, template_endpoint_enum(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(schema_path, inline_enums=True).serialize(head_path)) schemas = spec["components"]["schemas"] assert "FixedEnum" in schemas @@ -977,7 +1176,7 @@ def test_inline_enums_keeps_endpoint_referenced_enum(tmp_path): } -def test_inline_enums_does_not_inline_renamed_enums(input_path, tmp_path): +def test_inline_enums_does_not_inline_renamed_enums(input_path, tmp_path, oas_version): """Test that inlining a renamed enum does not bypass the type guard. When the endpoint refers to a LinkML ``enum`` under a different OpenAPI name, the @@ -985,17 +1184,17 @@ def test_inline_enums_does_not_inline_renamed_enums(input_path, tmp_path): would be inlined away, leaving the endpoint's ``$ref`` dangling. """ schema_path = input_path("openapi/schema_types_and_enums.yaml") - head_path = write_template(tmp_path, TEMPLATE_RENAMED_TYPE) + head_path = write_template(tmp_path, template_renamed_type(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(schema_path, inline_enums=True).serialize(head_path)) schemas = spec["components"]["schemas"] assert "Fixed" in schemas - assert schemas["Fixed"]["enum"] == ["fixed-value"] + assert_fixed_value(schemas["Fixed"], "fixed-value", oas_version) assert spec["paths"]["/fixed"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] == { "$ref": "#/components/schemas/Fixed" } -def test_inline_enums_shared_enum_no_yaml_anchors(tmp_path): +def test_inline_enums_shared_enum_no_yaml_anchors(tmp_path, oas_version): """Test that inlining a shared enum does not emit YAML anchors. When the same enum is referenced from two classes, the inlined copy must not be the @@ -1004,7 +1203,7 @@ def test_inline_enums_shared_enum_no_yaml_anchors(tmp_path): contain no anchors or aliases. """ schema_path = load_schema(SCHEMA_SHARED_ENUM) - head_path = write_template(tmp_path, TEMPLATE_SHARED_ENUM) + head_path = write_template(tmp_path, template_shared_enum(oas_version=oas_version)) result = OpenApiGenerator(schema_path, inline_enums=True).serialize(head_path) spec = yaml.safe_load(result) color_foo = spec["components"]["schemas"]["Foo"]["properties"]["color"] @@ -1019,7 +1218,7 @@ def test_inline_enums_shared_enum_no_yaml_anchors(tmp_path): assert color_foo is not color_bar -def test_inline_enums_preserves_slot_description(tmp_path): +def test_inline_enums_preserves_slot_description(tmp_path, oas_version): """Test that inlining an enum keeps the slot-level description of the referencing property. A property that references an enum carries its own ``description`` next to the @@ -1028,7 +1227,7 @@ def test_inline_enums_preserves_slot_description(tmp_path): must not eclipse it). """ schema_path = load_schema(SCHEMA_ENUM_SLOT_DESCRIPTION) - head_path = write_template(tmp_path, TEMPLATE_ENUM_SLOT_DESCRIPTION) + head_path = write_template(tmp_path, template_enum_slot_description(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(schema_path, inline_enums=True).serialize(head_path)) color = spec["components"]["schemas"]["Foo"]["properties"]["color"] assert color["enum"] == ["FOO", "BAR"] @@ -1054,7 +1253,7 @@ def _refs(obj): assert ref.removeprefix("#/components/schemas/") in schema_names -def test_lowercase_class_name_preserved(tmp_path, kitchen_sink_path): +def test_lowercase_class_name_preserved(tmp_path, kitchen_sink_path, oas_version): """Test that a lowercase LinkML class name is preserved, not camelCased, in the spec. ``JsonSchemaGenerator`` camelCases ``$defs`` keys unless ``preserve_names=True``. @@ -1063,7 +1262,7 @@ def test_lowercase_class_name_preserved(tmp_path, kitchen_sink_path): is keyed ``Activity`` while the ``$ref`` from ``Dataset`` points to ``activity``, yielding a missing schema and a dangling reference. """ - head_path = write_template(tmp_path, TEMPLATE_LOWERCASE_CLASS) + head_path = write_template(tmp_path, template_lowercase_class(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(kitchen_sink_path).serialize(head_path)) schemas = spec["components"]["schemas"] # the LinkML name is preserved verbatim, not camelCased @@ -1072,28 +1271,28 @@ def test_lowercase_class_name_preserved(tmp_path, kitchen_sink_path): # Dataset references the activity schema under its original name assert schemas["Dataset"]["properties"]["activities"]["items"] == {"$ref": "#/components/schemas/activity"} # the produced spec is valid (no dangling reference) - assert validate(spec, cls=OpenAPIV30SpecValidator) is None + assert validate(spec, cls=OAS_VALIDATORS[oas_version]) is None -def test_dangling_reference_raises(tmp_path, kitchen_sink_path): +def test_dangling_reference_raises(tmp_path, kitchen_sink_path, oas_version): """Test that a generated spec containing an unresolvable $ref is rejected. The template declares a ``Foo`` schema sourced from a non-existent LinkML class, so no schema is generated for it while an endpoint still references it. The generator must detect the dangling ``$ref`` and fail loudly. """ - head_path = write_template(tmp_path, TEMPLATE_DANGLING_REF) + head_path = write_template(tmp_path, template_dangling_ref(oas_version=oas_version)) with pytest.raises(ValueError, match="Dangling .ref"): OpenApiGenerator(kitchen_sink_path).serialize(head_path) -def test_dangling_reference_reports_all(tmp_path, kitchen_sink_path): +def test_dangling_reference_reports_all(tmp_path, kitchen_sink_path, oas_version): """All dangling ``$ref`` targets must be gathered and reported together, not just the first one. The template declares two schemas (``Foo`` and ``Bar``) sourced from non-existent LinkML classes, each referenced by its own endpoint. The single raised error must mention both. """ - head_path = write_template(tmp_path, TEMPLATE_DANGLING_REFS_MULTIPLE) + head_path = write_template(tmp_path, template_dangling_refs_multiple(oas_version=oas_version)) with pytest.raises(ValueError, match="Dangling .ref") as exc_info: OpenApiGenerator(kitchen_sink_path).serialize(head_path) message = str(exc_info.value) @@ -1101,17 +1300,17 @@ def test_dangling_reference_reports_all(tmp_path, kitchen_sink_path): assert "#/components/schemas/Bar" in message -def test_refs_to_non_schema_components_allowed(tmp_path, kitchen_sink_path): +def test_refs_to_non_schema_components_allowed(tmp_path, kitchen_sink_path, oas_version): """Test that $refs to reusable components other than schemas (e.g. responses) are allowed. The dangling-reference check must resolve every internal ``$ref`` against its own ``components`` section rather than assuming all targets live under ``schemas``. """ - head_path = write_template(tmp_path, TEMPLATE_SHARED_RESPONSES) + head_path = write_template(tmp_path, template_shared_responses(oas_version=oas_version)) spec = yaml.safe_load(OpenApiGenerator(kitchen_sink_path).serialize(head_path)) # the reusable response survives and is still referenced by the endpoint assert "NotFound" in spec["components"]["responses"] assert spec["paths"]["/foo"]["get"]["responses"]["404"] == {"$ref": "#/components/responses/NotFound"} # the schema is generated as usual assert "Person" in spec["components"]["schemas"] - assert validate(spec, cls=OpenAPIV30SpecValidator) is None + assert validate(spec, cls=OAS_VALIDATORS[oas_version]) is None diff --git a/tests/linkml/test_scripts/test_gen_openapi.py b/tests/linkml/test_scripts/test_gen_openapi.py index dce6f8b8bf..de001b3d72 100644 --- a/tests/linkml/test_scripts/test_gen_openapi.py +++ b/tests/linkml/test_scripts/test_gen_openapi.py @@ -1,26 +1,25 @@ +import pytest from click.testing import CliRunner from linkml.generators.openapigen import cli from tests.conftest import KITCHEN_SINK_PATH -OPENAPI_TEMPLATE_PATH = str( - __import__("pathlib").Path(__file__).parent.parent - / "test_generators" - / "input" - / "openapi" - / "spec-head.openapi.yaml" -) +OPENAPI_TEMPLATE_PATH_PREFIX = str(__import__("pathlib").Path(__file__).parent.parent / "test_generators" / "input") def test_help(): runner = CliRunner() result = runner.invoke(cli, ["--help"]) - assert "Generate an OpenAPI v3.0.3 spec" in result.output + assert "Generate an OpenAPI YAML" in result.output -def test_valid_call(): +@pytest.mark.parametrize( + "template_path", + ["openapi/spec-head-v30.openapi.yaml", "openapi/spec-head-v31.openapi.yaml"], +) +def test_valid_call(template_path): runner = CliRunner() - result = runner.invoke(cli, [KITCHEN_SINK_PATH, "--template", OPENAPI_TEMPLATE_PATH]) + result = runner.invoke(cli, [KITCHEN_SINK_PATH, "--template", f"{OPENAPI_TEMPLATE_PATH_PREFIX}/{template_path}"]) assert result.exit_code == 0 assert "MarriageEvent" in result.output assert "MedicalEvent" in result.output @@ -31,5 +30,5 @@ def test_missing_template(): runner = CliRunner() result = runner.invoke(cli, [KITCHEN_SINK_PATH], standalone_mode=False) assert result.exit_code == 0 - assert "openapi: 3.0.3" in result.output + assert "openapi: x.y.z" in result.output assert "x-linkml-schema:" in result.output From 30203e58a7b4cb1dd0cde9c1eb1c468e360fe867 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:35:36 -0500 Subject: [PATCH 21/24] build(deps-dev): bump tox from 4.63.0 to 4.64.1 --- packages/linkml/pyproject.toml | 2 +- uv.lock | 183 ++++++++++++++++----------------- 2 files changed, 92 insertions(+), 93 deletions(-) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index 98407591cd..298f6244dc 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -97,7 +97,7 @@ dev = [ "nbconvert", "nbformat", "coverage>=7.16.0", - "tox>=4.63.0", + "tox>=4.64.1", "tox-uv", "myst-nb>=1.4.0; python_version >= '3.10'", "sphinx-design >= 0.5.0", diff --git a/uv.lock b/uv.lock index 356389fd78..91c43e8e95 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-09-24T21:22:43.997041597Z" exclude-newer-span = "P7D" [manifest] @@ -661,7 +661,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -734,7 +734,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1172,7 +1172,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1714,17 +1714,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } wheels = [ @@ -1742,17 +1742,17 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/34/29b18c62e39ee2f7a6a3bba7efd952729d8aadd45ca17efc34453b717665/ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731", size = 4396932, upload-time = "2025-09-29T10:55:53.948Z" } wheels = [ @@ -1773,7 +1773,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2147,7 +2147,7 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.6.2" +version = "4.6.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, @@ -2166,9 +2166,9 @@ dependencies = [ { name = "traitlets" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/51c0c856ab286bdaf5709cf61ed13584ed9d4bee906479707da45b11b353/jupyterlab-4.6.2.tar.gz", hash = "sha256:e18ce8b34f3de350e93cd5b2c4f3ae884cbe266eb76bf5d6825a4ed34c13bcff", size = 28183650, upload-time = "2026-07-21T12:05:24.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/8d/995cc142f6083346b35e7d3eadc5a6717ee89ce64ea53577010ac493bc3c/jupyterlab-4.6.4.tar.gz", hash = "sha256:404f49b081819378524886c9db66dba57a5565981eff885830df1baba3a17df5", size = 28335647, upload-time = "2026-09-21T15:25:18.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/1f/e39b248c76bb3736bc05a9491a6aa1315414c32dea12f548ecc1b24c758e/jupyterlab-4.6.2-py3-none-any.whl", hash = "sha256:5964447036629adfcd3fc0969effc1da6f47d2cbd0a60b2c2eea7c31be0ec6a8", size = 17166703, upload-time = "2026-07-21T12:05:19.818Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/072bc0d3c6d45d414062a06af7ee81c02a2c12d769c358ec9919f9995ffd/jupyterlab-4.6.4-py3-none-any.whl", hash = "sha256:15b13f991d3985129c797eb84d9949eeb8b6615e14b444868e642411f2c418b2", size = 17171596, upload-time = "2026-09-21T15:25:14.123Z" }, ] [[package]] @@ -2563,7 +2563,7 @@ dev = [ { name = "sqlalchemy-bigquery", specifier = ">=1.17.2" }, { name = "testcontainers", specifier = "==4.15.0" }, { name = "tomli-w", specifier = ">=1.2.0" }, - { name = "tox", specifier = ">=4.63.0" }, + { name = "tox", specifier = ">=4.64.1" }, { name = "tox-uv" }, ] docs = [ @@ -3175,7 +3175,7 @@ wheels = [ [[package]] name = "notebook" -version = "7.6.0" +version = "7.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-builder" }, @@ -3185,9 +3185,9 @@ dependencies = [ { name = "notebook-shim" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/44/d5c65783f490298473bb1c05722e05ee2256231389559c2c5ae0a3e5d975/notebook-7.6.0.tar.gz", hash = "sha256:ea13e79e601bf273074895fdfb17dd3f2da916d3c045e0b9c47d18b16ab62481", size = 5497344, upload-time = "2026-06-18T16:18:55.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/ac/aedb759dc683dcd129f6c75271bab5eb23020b0f7969163c726343718e1e/notebook-7.6.3.tar.gz", hash = "sha256:e2c08e469c0ae20bb0b3214f0ab77e79653317a2f8e5b34c10361c66874a5b50", size = 5501618, upload-time = "2026-09-21T18:07:24.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d1/e617c40db57ff40e75f43a7d4d1c305e3a54c053ab5cb0534a6c314664f9/notebook-7.6.0-py3-none-any.whl", hash = "sha256:98aa2811b54ac191321d5dfce12ca700f8a511a33a26e4de2fa106a357c43d6a", size = 5544575, upload-time = "2026-06-18T16:18:52.551Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f7/907b98438cf00bdc4e296570058f29a347214bb42338a48e3b7eae58ae2a/notebook-7.6.3-py3-none-any.whl", hash = "sha256:ad7e0eb765fba836cd4a2ab0c7a3a26cde1d91665fbf6f533b6ae7b2de6d88d2", size = 5548452, upload-time = "2026-09-21T18:07:21.563Z" }, ] [[package]] @@ -4357,15 +4357,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.5.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/51/276f964496a5714ab9f320896195639086881c2b39c03b5ad13de84acbb8/python_discovery-1.5.0.tar.gz", hash = "sha256:3e014c6327154d3dda27939a9a0dc9c5c000439f1906d3f303b48f984bd2ecef", size = 72483, upload-time = "2026-07-21T13:14:14.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/7b/14882602ddee241d7984a742fcb423cb4a30fb0d6efc546ac3129fba475a/python_discovery-1.5.0-py3-none-any.whl", hash = "sha256:70c4fc61b4e7404e44f01d6fc44a715c4d685ca6cea83d295922f05891877c98", size = 34205, upload-time = "2026-07-21T13:14:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, ] [[package]] @@ -4965,23 +4964,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -4999,23 +4998,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals-py" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ @@ -5445,24 +5444,24 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.8" +version = "6.5.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/61/53d562a57b28c08eda40b258c0f975e360541943ad7c7bef897a40caafda/tornado-6.5.10.tar.gz", hash = "sha256:a6b1ccd08c04b4a06fb5aeb381be99de5ad1e5375c1785e31d78c880feb57687", size = 537910, upload-time = "2026-09-15T13:47:48.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, - { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, - { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, - { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, - { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, - { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, - { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9261783640e23258694a9ff0795df430a5a7b0a651d3dd53dd0969ad6be16da7", size = 465883, upload-time = "2026-09-15T13:47:35.463Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f5/cd7be26c34a3315532f3aef5f092465da8f59c334dd439d3c14aaef16461/tornado-6.5.10-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:83e6cf438b106c6b3852d70960967bb1b70c87438050dca0981e4b9aa751a4c1", size = 464046, upload-time = "2026-09-15T13:47:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bdf942448169e5336451d0494d7e3d81cfa726d5aa312affdc4682dd62a62f6d", size = 467096, upload-time = "2026-09-15T13:47:38.559Z" }, + { url = "https://files.pythonhosted.org/packages/29/17/cc35dff68272d685cffd8600ffafbd8067e7d05e7348d9f80caddffbbd5f/tornado-6.5.10-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69acca6501eed74582b76dbbceee2a91613f54728e3e418346000d7103101676", size = 468067, upload-time = "2026-09-15T13:47:40.085Z" }, + { url = "https://files.pythonhosted.org/packages/c3/01/6e5349b4e1a53a4b4972a6716785e1fe7407f312063c3972690af8ff301b/tornado-6.5.10-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:66aaa3f57d30c6e6becee83ff28055d5930ac724214bde99393eefda83d5e015", size = 467901, upload-time = "2026-09-15T13:47:41.576Z" }, + { url = "https://files.pythonhosted.org/packages/28/5e/b4facf94370dba006819c8d304376f8b9fbec6b935b5e51bf45823a9790b/tornado-6.5.10-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bd192b959f9128fb99b8898148070ba4574c9589b78bce42d1851131fe85828", size = 467308, upload-time = "2026-09-15T13:47:43.145Z" }, + { url = "https://files.pythonhosted.org/packages/56/ae/047938e828cafc8eca4c908fafb6588fee944e3af39a0af9d7b602499ae5/tornado-6.5.10-cp39-abi3-win32.whl", hash = "sha256:302eb1e0e3e159314eb591920529fdea80acca92df5510a2cec5bbd4f099ec72", size = 468387, upload-time = "2026-09-15T13:47:44.556Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/5901517f05affd752490f6a654ba31b7474664e8dd80bd045a00c220bd88/tornado-6.5.10-cp39-abi3-win_amd64.whl", hash = "sha256:37ae8f150cecfdbf747fc4e12f5e9a97ecd8cf1d4cdb3f119e2de84b11196918", size = 468828, upload-time = "2026-09-15T13:47:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/fd497f3a7f7b74bb04f4b94536b5c9f80742b5d50501fd27977652ddec16/tornado-6.5.10-cp39-abi3-win_arm64.whl", hash = "sha256:ce045d3c298fddd30e89a2777f97039d1b641eb9518ac7b26a4721903539c694", size = 467847, upload-time = "2026-09-15T13:47:47.283Z" }, ] [[package]] name = "tox" -version = "4.63.0" +version = "4.64.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -5478,9 +5477,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/93/ce52def83be73dcb86c0c5985f40808917f09828c1616d4018206c3dbba3/tox-4.63.0.tar.gz", hash = "sha256:fb7105ef125b371e31797cfa615e5a160de3d8ef29b86dca7fcbd9048da3a9d8", size = 310965, upload-time = "2026-09-19T16:45:35.275Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/88/b0fff358c90fc49e0e619604d17b1edd26ab931853ed73a559f3b5bdf0c8/tox-4.64.1.tar.gz", hash = "sha256:d10dd88c32e9f15f0ec831f9ebacb9b253f671fd1287d49bead2852fc6225271", size = 314417, upload-time = "2026-09-24T01:25:03.947Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/4d/d0e37d254b4ad5492d63ea60981365a5122261b5954a9d952d8b15283e26/tox-4.63.0-py3-none-any.whl", hash = "sha256:e8c560a9124e127c5e0547912d2ae1ef948dbba2fdea0b8d506e609a126b2e47", size = 229324, upload-time = "2026-09-19T16:45:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/ad62157d57ee9f2e1e05c456f2e6ac1294ddd1fa52913f37fc4355e66598/tox-4.64.1-py3-none-any.whl", hash = "sha256:3089cfb112f8c5a1fcc652b49e4c708bb00cbaa7c908078ebe470e08f112de4c", size = 231589, upload-time = "2026-09-24T01:25:02.206Z" }, ] [[package]] @@ -5648,11 +5647,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -5683,7 +5682,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.5.1" +version = "21.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -5692,9 +5691,9 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/3b/02608bb39c6b6f7d59ca62b04d704cf61e85361a7123b6394bca275661e1/virtualenv-21.12.1.tar.gz", hash = "sha256:be5a0a62cb2d1529ff6999652e2e7826f95bf7faa9b96b88cc39847c023d90a0", size = 5397187, upload-time = "2026-09-24T19:03:48.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8c/692cf235722ee45c612630d2b49b3d1765892f7e440055365ea1b59c659e/virtualenv-21.12.1-py3-none-any.whl", hash = "sha256:878db9e963d0ef0b38561a344a4f9b12dc5c7741b6245d7ed10e9291cfea28bc", size = 5486096, upload-time = "2026-09-24T19:03:45.985Z" }, ] [[package]] From 62d30bf16eba480a986bd12550deba97462029a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:53:19 +0000 Subject: [PATCH 22/24] build(deps): bump the github-actions group with 5 updates --- .github/workflows/check-external-links.yaml | 2 +- .github/workflows/dependency-audit.yaml | 9 ++++++--- .github/workflows/doc-pages.yaml | 2 +- .github/workflows/docker-build.yaml | 6 +++--- .github/workflows/docs-test.yaml | 2 +- .github/workflows/main.yaml | 18 +++++++++--------- .github/workflows/metamodel-compat.yaml | 2 +- .github/workflows/pypi-publish.yaml | 2 +- .github/workflows/rustgen.yaml | 4 ++-- .github/workflows/typedb-integration.yaml | 2 +- .../workflows/update-feature-dashboard.yaml | 2 +- 11 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.github/workflows/check-external-links.yaml b/.github/workflows/check-external-links.yaml index a99ad80708..fe2ad217d0 100644 --- a/.github/workflows/check-external-links.yaml +++ b/.github/workflows/check-external-links.yaml @@ -28,7 +28,7 @@ jobs: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} diff --git a/.github/workflows/dependency-audit.yaml b/.github/workflows/dependency-audit.yaml index 1f6916dc13..a58832496c 100644 --- a/.github/workflows/dependency-audit.yaml +++ b/.github/workflows/dependency-audit.yaml @@ -38,7 +38,7 @@ jobs: # Pin uv to a known-good, recent release. - name: Install uv and setup uv caching - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true @@ -56,7 +56,10 @@ jobs: # # So gate the audit on whether the change actually altered dependencies, # relative to each event's natural base: - # * pull_request -> the PR base + # * pull_request -> the base the checked-out merge commit was built on + # (HEAD^1). Not github.event.pull_request.base.sha: that can be an + # older main commit, so the diff would include main's own uv.lock + # bumps and audit a PR that changed no dependencies. # * push (main) -> the commit before the push (github.event.before) # * merge_group -> the queue base # * otherwise (workflow_dispatch, first/force push) -> audit @@ -78,7 +81,7 @@ jobs: run: | set -euo pipefail case "${{ github.event_name }}" in - pull_request) base="${{ github.event.pull_request.base.sha }}" ;; + pull_request) base="$(git rev-parse HEAD^1)" ;; merge_group) base="${{ github.event.merge_group.base_sha }}" ;; push) base="${{ github.event.before }}" ;; *) base="" ;; diff --git a/.github/workflows/doc-pages.yaml b/.github/workflows/doc-pages.yaml index 27ffbe7353..627b248dc8 100644 --- a/.github/workflows/doc-pages.yaml +++ b/.github/workflows/doc-pages.yaml @@ -24,7 +24,7 @@ jobs: git fetch upstream --tags - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 7bd585d539..231b39a45e 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -48,10 +48,10 @@ jobs: echo "Ref: ${{ github.ref }}" - name: Set up QEMU - uses: docker/setup-qemu-action@v4.2.0 + uses: docker/setup-qemu-action@v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.3.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Login to DockerHub if: startsWith(github.ref, 'refs/tags/v') @@ -61,7 +61,7 @@ jobs: password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Build and push - uses: docker/build-push-action@v7.3.0 + uses: docker/build-push-action@v7.4.0 with: context: . platforms: linux/amd64,linux/arm64/v8 diff --git a/.github/workflows/docs-test.yaml b/.github/workflows/docs-test.yaml index fd1cbd69e7..7f2f0a757b 100644 --- a/.github/workflows/docs-test.yaml +++ b/.github/workflows/docs-test.yaml @@ -36,7 +36,7 @@ jobs: git fetch upstream --tags - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 85c7aa330d..a8c122e1bb 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} @@ -76,7 +76,7 @@ jobs: git fetch upstream --tags - name: Install uv and setup uv caching - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true @@ -105,7 +105,7 @@ jobs: shell: bash - name: Upload linkml coverage report if: github.repository == 'linkml/linkml' && github.actor != 'dependabot[bot]' - uses: codecov/codecov-action@v7.0.0 + uses: codecov/codecov-action@v7.1.1 with: name: codecov-linkml-${{ matrix.os }}-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} @@ -114,7 +114,7 @@ jobs: fail_ci_if_error: true - name: Upload runtime coverage report if: github.repository == 'linkml/linkml' && github.actor != 'dependabot[bot]' - uses: codecov/codecov-action@v7.0.0 + uses: codecov/codecov-action@v7.1.1 with: name: codecov-runtime-${{ matrix.os }}-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} @@ -139,7 +139,7 @@ jobs: - name: Check out repository uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true @@ -160,7 +160,7 @@ jobs: uv run coverage report -m - name: Upload linkml slow test coverage if: github.repository == 'linkml/linkml' && github.actor != 'dependabot[bot]' - uses: codecov/codecov-action@v7.0.0 + uses: codecov/codecov-action@v7.1.1 with: name: codecov-linkml-slow-${{ matrix.os }}-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} @@ -189,7 +189,7 @@ jobs: - name: Check out repository uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true @@ -207,7 +207,7 @@ jobs: uv run pytest tests/linkml/test_notebooks/ -m "not kroki" --cov --cov-report xml:coverage-notebooks.xml --cov-report term-missing - name: Upload notebook coverage if: github.repository == 'linkml/linkml' && github.actor != 'dependabot[bot]' - uses: codecov/codecov-action@v7.0.0 + uses: codecov/codecov-action@v7.1.1 with: name: codecov-notebooks-${{ matrix.os }}-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} @@ -232,7 +232,7 @@ jobs: python-version: 3.13 - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} - name: Build source and wheel archives diff --git a/.github/workflows/metamodel-compat.yaml b/.github/workflows/metamodel-compat.yaml index 86917fbcb8..b9e7396eca 100644 --- a/.github/workflows/metamodel-compat.yaml +++ b/.github/workflows/metamodel-compat.yaml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml index d906594493..d5337b167c 100644 --- a/.github/workflows/pypi-publish.yaml +++ b/.github/workflows/pypi-publish.yaml @@ -23,7 +23,7 @@ jobs: python-version: 3.13 - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} diff --git a/.github/workflows/rustgen.yaml b/.github/workflows/rustgen.yaml index b53f1a8ec5..14f62a1234 100644 --- a/.github/workflows/rustgen.yaml +++ b/.github/workflows/rustgen.yaml @@ -34,7 +34,7 @@ jobs: git fetch upstream --tags - name: Install uv and setup uv caching - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true @@ -63,7 +63,7 @@ jobs: - name: Upload coverage report if: github.repository == 'linkml/linkml' && github.actor != 'dependabot[bot]' - uses: codecov/codecov-action@v7.0.0 + uses: codecov/codecov-action@v7.1.1 with: name: codecov-results-rustgen token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/typedb-integration.yaml b/.github/workflows/typedb-integration.yaml index a8de2511de..c812050fa6 100644 --- a/.github/workflows/typedb-integration.yaml +++ b/.github/workflows/typedb-integration.yaml @@ -31,7 +31,7 @@ jobs: --health-start-period 30s steps: - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v10.0.1 + - uses: astral-sh/setup-uv@v10.2.0 - uses: actions/setup-python@v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/update-feature-dashboard.yaml b/.github/workflows/update-feature-dashboard.yaml index 02fb8fba59..cfde207543 100644 --- a/.github/workflows/update-feature-dashboard.yaml +++ b/.github/workflows/update-feature-dashboard.yaml @@ -32,7 +32,7 @@ jobs: git fetch upstream --tags - name: Install uv - uses: astral-sh/setup-uv@v10.0.1 + uses: astral-sh/setup-uv@v10.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true From 084aaa9489b0a737536d86d281a147a701a2171b Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 12:03:48 +0200 Subject: [PATCH 23/24] test(rdf): trim the diff-stable tests and reuse the existing fixture Addresses the second review round. Remove the installed-wheel checks and the CI step that drove them. They were a one-off apparatus for something the test suite already has a mechanism for, and part of it only asserted that uv builds wheel metadata correctly from pyproject.toml rather than testing anything about linkml. Test the missing extra with a new mock_missing_import fixture built on the existing MockImportErrorFinder, so the next optional dependency needs no fixture of its own. Guard the import the way bigquerygen already does: find_spec raises under that fixture, so the install hint never surfaced. Drop the default-value test, restore the pre-existing sort test, and cut the CLI matrix to the single case that shows the flag reaching the serializer. Record the convention in AGENTS.md, point MockImportErrorFinder at the general fixture, and link the guide from the diff_stable parameter. --- .github/tests/test_diff_stable_install.py | 147 ------------------ .github/workflows/main.yaml | 26 ---- AGENTS.md | 2 + .../linkml_runtime/utils/rdf_canonicalize.py | 9 +- tests/conftest.py | 37 ++++- tests/linkml/test_scripts/test_diff_stable.py | 90 +++-------- .../test_utils/test_rdf_canonicalize.py | 19 ++- 7 files changed, 70 insertions(+), 260 deletions(-) delete mode 100644 .github/tests/test_diff_stable_install.py diff --git a/.github/tests/test_diff_stable_install.py b/.github/tests/test_diff_stable_install.py deleted file mode 100644 index ef2927ccf4..0000000000 --- a/.github/tests/test_diff_stable_install.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Run each selection in a fresh environment containing the built wheels.""" - -import importlib -import re -import sys -import sysconfig -from importlib.metadata import metadata, version -from importlib.util import find_spec -from pathlib import Path - -import pytest -from packaging.requirements import Requirement - -_SCHEMA = """id: https://example.org/install -name: install -prefixes: - linkml: https://w3id.org/linkml/ - ex: https://example.org/install/ -default_prefix: ex -imports: [linkml:types] -classes: - Thing: - attributes: - label: - range: string -""" - - -def _assert_installed(package: str) -> None: - """Require imports from this environment's installed package directory.""" - module = importlib.import_module(package) - assert Path(module.__file__).resolve().is_relative_to(Path(sysconfig.get_path("purelib")).resolve()) - - -def _assert_runtime_requirements() -> None: - """Check ordinary compatibility and the extra's forwarding in wheel metadata.""" - requirements = [Requirement(value) for value in metadata("linkml").get_all("Requires-Dist")] - (ordinary,) = [r for r in requirements if r.name == "linkml-runtime" and r.marker is None] - (forwarded,) = [r for r in requirements if r.name == "linkml-runtime" and r.marker is not None] - assert ordinary.extras == set() - assert forwarded.extras == {"diff-stable"} - assert forwarded.marker.evaluate({"extra": "diff-stable"}) - assert not forwarded.marker.evaluate({"extra": ""}) - assert ordinary.specifier == forwarded.specifier - assert ordinary.specifier.contains(version("linkml-runtime"), prereleases=True) - - -def _assert_diff_stable_labels(output: str) -> None: - """Require actual blank-node terms with the enabled label syntax.""" - import pyoxigraph as ox - - quads = ox.parse(output, format=ox.RdfFormat.TURTLE) - nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} - assert nodes - assert all(re.fullmatch(r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", node.value) for node in nodes) - - -def test_runtime_without_extra() -> None: - """Ordinary runtime installs work and explain how to enable the option.""" - assert find_spec("diffable_rdf") is None - _assert_installed("linkml_runtime") - from rdflib import Graph - from rdflib.compare import isomorphic - - from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph - - graph = Graph().parse(data=' [ "v" ] .') - for kwargs in ({}, {"diff_stable": False}): - output = canonicalize_rdf_graph(graph, **kwargs) - assert isomorphic(graph, Graph().parse(data=output, format="turtle")) - assert "diffable_rdf" not in sys.modules - with pytest.raises(ImportError, match=r"linkml-runtime\[diff-stable\]"): - canonicalize_rdf_graph(graph, diff_stable=True) - - -def test_linkml_without_extra(tmp_path: Path) -> None: - """Generator imports, help and default output do not require the extra.""" - assert find_spec("diffable_rdf") is None - _assert_installed("linkml_runtime") - _assert_installed("linkml") - _assert_runtime_requirements() - from click.testing import CliRunner - from rdflib import Graph - from rdflib.namespace import RDF, SH - - from linkml.generators.shaclgen import cli - - for name in ("owlgen", "rdfgen", "shaclgen", "shexgen"): - command = importlib.import_module(f"linkml.generators.{name}").cli - result = CliRunner().invoke(command, ["--help"]) - assert result.exit_code == 0, result.output - schema = tmp_path / "schema.yaml" - schema.write_text(_SCHEMA, encoding="utf-8") - result = CliRunner().invoke(cli, [str(schema)]) - assert result.exit_code == 0, result.output - graph = Graph().parse(data=result.stdout, format="turtle") - assert list(graph.subjects(RDF.type, SH.NodeShape)) - assert "diffable_rdf" not in sys.modules - result = CliRunner().invoke(cli, [str(schema), "--diff-stable"]) - assert result.exit_code != 0 - assert isinstance(result.exception, ImportError) - assert "linkml[diff-stable]" in str(result.exception) - - -def test_runtime_with_extra() -> None: - """The runtime extra supplies the relabeler and preserves the source graph.""" - assert find_spec("diffable_rdf") is not None - _assert_installed("linkml_runtime") - from rdflib import Graph - from rdflib.compare import isomorphic - - from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph - - graph = Graph().parse(data=' [ "v" ] .') - canonicalize_rdf_graph(graph) - assert "diffable_rdf" not in sys.modules - output = canonicalize_rdf_graph(graph, output_format="nt", diff_stable=True) - parsed = Graph().parse(data=output, format="nt") - assert len(parsed) == len(graph) - assert isomorphic(graph, parsed) - _assert_diff_stable_labels(output) - _assert_installed("diffable_rdf") - - -def test_linkml_with_extra(tmp_path: Path) -> None: - """The LinkML extra installs the relabeler through the runtime extra.""" - assert find_spec("diffable_rdf") is not None - _assert_installed("linkml_runtime") - _assert_installed("linkml") - _assert_runtime_requirements() - from click.testing import CliRunner - from rdflib import Graph, Namespace - from rdflib.namespace import RDF, SH - - from linkml.generators.shaclgen import cli - - schema = tmp_path / "schema.yaml" - schema.write_text(_SCHEMA, encoding="utf-8") - result = CliRunner().invoke(cli, [str(schema), "--diff-stable"]) - assert result.exit_code == 0, result.output - graph = Graph().parse(data=result.stdout, format="turtle") - ex = Namespace("https://example.org/install/") - assert (ex.Thing, RDF.type, SH.NodeShape) in graph - assert (ex.Thing, SH.targetClass, ex.Thing) in graph - assert {graph.value(node, SH.path) for node in graph.objects(ex.Thing, SH.property)} == {ex.label} - _assert_diff_stable_labels(result.stdout) - _assert_installed("diffable_rdf") diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index dcc4c52ca7..a8c122e1bb 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -237,29 +237,3 @@ jobs: version: ${{ env.UV_VERSION }} - name: Build source and wheel archives run: uv build --all-packages - - name: Test optional diff-stable installations - shell: bash - run: | - shopt -s nullglob - runtime_wheels=(dist/linkml_runtime-*.whl) - linkml_wheels=(dist/linkml-*.whl) - test "${#runtime_wheels[@]}" -eq 1 - test "${#linkml_wheels[@]}" -eq 1 - for scenario in runtime_without_extra linkml_without_extra runtime_with_extra linkml_with_extra; do - scenario_env="$RUNNER_TEMP/$scenario" - uv venv --python 3.13 "$scenario_env" - packages=("${runtime_wheels[0]}" pytest) - case "$scenario" in - runtime_with_extra) packages=("${runtime_wheels[0]}[diff-stable]" pytest) ;; - linkml_without_extra) packages+=("${linkml_wheels[0]}") ;; - linkml_with_extra) packages+=("${linkml_wheels[0]}[diff-stable]") ;; - esac - uv pip install --python "$scenario_env/bin/python" "${packages[@]}" - uv pip check --python "$scenario_env/bin/python" - ( - cd "$RUNNER_TEMP" - uv run --no-project "$scenario_env/bin/python" -I -m pytest \ - --noconftest --import-mode=importlib \ - "$GITHUB_WORKSPACE/.github/tests/test_diff_stable_install.py" -k "$scenario" -v - ) - done diff --git a/AGENTS.md b/AGENTS.md index 772f7c59ab..c9a178e6c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ All commands use `uv run` prefix (e.g., `uv run pytest`). * Do not "fix" issues by changing or weakening test conditions. Try harder, or ask questions if a test fails. * Avoid try/except blocks, these can mask bugs * Failing fast is a good principle +* Optional dependencies are the documented exception to the two rules above. Guard the import, not the spec: `try: import x` / `except ImportError as exc: raise ImportError("x is required. Install with: pip install 'linkml[extra]'") from exc` - see `generators/bigquerygen.py`. `importlib.util.find_spec` raises under the test fixture below, which hides your message. +* Testing that an optional dependency is absent is not a mock test: use the `mock_missing_import` fixture in `tests/conftest.py`. Do not build isolated environments in CI for this. * Follow the DRY principle * Avoid repeating chunks of code, but also avoid premature over-abstraction * Declarative principles are favored diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index d7a894be17..bc06a969b8 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -39,7 +39,6 @@ import io import re import warnings -from importlib.util import find_spec import pyoxigraph as ox import rdflib @@ -305,16 +304,18 @@ def canonicalize_rdf_graph( :param diff_stable: Use diffable-rdf labels to reduce blank-node churn across edits. Requires the ``diff-stable`` extra; disabled by default. The non-standard RDF fallback warns when it cannot apply these labels. + See https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control :return: Deterministic string serialization of the graph. :raises ImportError: If diff-stable labels are requested without diffable-rdf. """ if diff_stable: - if find_spec("diffable_rdf") is None: + try: + from diffable_rdf import wl_relabel_quads + except ImportError as exc: raise ImportError( "diff_stable=True requires diffable-rdf. Install " "'linkml-runtime[diff-stable]' or 'linkml[diff-stable]'." - ) - from diffable_rdf import wl_relabel_quads + ) from exc ox_format = _FORMAT_MAP.get(output_format.lower()) if ox_format is None: diff --git a/tests/conftest.py b/tests/conftest.py index bcf958b9e5..ba13d6f764 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -422,7 +422,8 @@ class MockImportErrorFinder(MetaPathFinder): """ Fake like we don't have a module when we really do. - see the ``mock_black_import`` fixture for example usage. + see the ``mock_missing_import`` fixture to hide any module, or ``mock_black_import`` + for a single-purpose example. .. note:: @@ -442,6 +443,40 @@ def find_spec(self, fullname, path, target): return None +@pytest.fixture(scope="function") +def mock_missing_import(): + """Pretend an optional dependency is not installed, for any module. + + Yields a callable taking a module name, so a test for one optional + dependency does not need a fixture of its own:: + + def test_requires_the_extra(mock_missing_import): + mock_missing_import("diffable_rdf") + with pytest.raises(ImportError, match="linkml-runtime[diff-stable]"): + ... + + See :class:`MockImportErrorFinder` for the caveat about reimporting + modules that already imported the one being hidden. + """ + removed = {} + finders = [] + + def hide(module: str) -> None: + for name, loaded in list(sys.modules.items()): + if name.startswith(module): + removed[name] = loaded + del sys.modules[name] + finder = MockImportErrorFinder(module) + finders.append(finder) + sys.meta_path.insert(0, finder) + + yield hide + + sys.modules.update(removed) + for finder in finders: + sys.meta_path.remove(finder) + + @pytest.fixture(scope="function") def mock_black_import(): """ diff --git a/tests/linkml/test_scripts/test_diff_stable.py b/tests/linkml/test_scripts/test_diff_stable.py index cd072aed38..d1c292276b 100644 --- a/tests/linkml/test_scripts/test_diff_stable.py +++ b/tests/linkml/test_scripts/test_diff_stable.py @@ -1,57 +1,15 @@ -"""CLI integration checks for optional diff-stable RDF labels.""" +"""CLI checks for the optional diff-stable RDF labels.""" import re from pathlib import Path -import click import pyoxigraph as ox import pytest from click.testing import CliRunner -from rdflib import Graph, Namespace, URIRef -from rdflib.namespace import OWL, RDF, SH -from linkml.generators.owlgen import OwlSchemaGenerator -from linkml.generators.owlgen import cli as owl_cli -from linkml.generators.rdfgen import RDFGenerator -from linkml.generators.rdfgen import cli as rdf_cli -from linkml.generators.shaclgen import ShaclGenerator from linkml.generators.shaclgen import cli as shacl_cli -from linkml.generators.shexgen import ShExGenerator -from linkml.generators.shexgen import cli as shex_cli -from linkml.utils.generator import Generator - -@pytest.mark.parametrize( - ("command", "args", "rdf_type"), - [ - pytest.param(owl_cli, [], OWL.Class, id="owlgen"), - pytest.param( - rdf_cli, [], URIRef("https://w3id.org/linkml/ClassDefinition"), id="rdfgen", marks=pytest.mark.network - ), - pytest.param(shacl_cli, [], SH.NodeShape, id="shaclgen"), - pytest.param( - shex_cli, - ["--format", "rdf"], - URIRef("http://www.w3.org/ns/shex#Shape"), - id="shexgen", - marks=pytest.mark.network, - ), - ], -) -@pytest.mark.parametrize( - ("flag", "label_pattern"), - [ - pytest.param("--no-diff-stable", r"c14n[0-9]+", id="disabled"), - pytest.param("--diff-stable", r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", id="enabled"), - ], -) -def test_diff_stable_cli( - tmp_path: Path, command: click.Command, args: list[str], rdf_type: URIRef, flag: str, label_pattern: str -) -> None: - """Every RDF CLI produces the requested labels and the expected class.""" - schema = tmp_path / "schema.yaml" - schema.write_text( - """id: https://example.org/cli +_SCHEMA = """id: https://example.org/cli name: cli prefixes: linkml: https://w3id.org/linkml/ @@ -63,37 +21,25 @@ def test_diff_stable_cli( attributes: label: range: string -""", - encoding="utf-8", - ) - result = CliRunner().invoke(command, [str(schema), flag, *args]) - assert result.exit_code == 0, result.output - quads = list(ox.parse(result.stdout, format=ox.RdfFormat.TURTLE)) - blank_nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} - assert blank_nodes - assert all(re.fullmatch(label_pattern, node.value) for node in blank_nodes) - graph = Graph().parse(data=result.stdout, format="turtle") - assert (Namespace("https://example.org/cli/").Thing, RDF.type, rdf_type) in graph +""" @pytest.mark.parametrize( - ("generator", "command"), + ("flag", "label_pattern"), [ - pytest.param(OwlSchemaGenerator, owl_cli, id="owlgen"), - pytest.param(RDFGenerator, rdf_cli, id="rdfgen"), - pytest.param(ShaclGenerator, shacl_cli, id="shaclgen"), - pytest.param(ShExGenerator, shex_cli, id="shexgen"), + pytest.param("--no-diff-stable", r"c14n[0-9]+", id="disabled"), + pytest.param("--diff-stable", r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", id="enabled", marks=pytest.mark.diffable_rdf), ], ) -def test_diff_stable_defaults_and_help(generator: type[Generator], command: click.Command) -> None: - """Defaults are off and terminal help points to the shared guide.""" - assert generator.diff_stable is False - (option,) = [param for param in command.params if param.name == "diff_stable"] - assert option.default is False - result = CliRunner().invoke(command, ["--help"]) - assert result.exit_code == 0 - assert "--diff-stable / --no-diff-stable" in result.output - assert "[default: no-diff-stable]" in result.output - assert "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" in "".join( - result.output.split() - ) +def test_the_flag_reaches_the_serializer(tmp_path: Path, flag: str, label_pattern: str) -> None: + """The CLI flag selects the blank-node labelling it names.""" + schema = tmp_path / "schema.yaml" + schema.write_text(_SCHEMA, encoding="utf-8") + + result = CliRunner().invoke(shacl_cli, [str(schema), flag]) + + assert result.exit_code == 0, result.output + quads = list(ox.parse(result.stdout, format=ox.RdfFormat.TURTLE)) + blank_nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} + assert blank_nodes + assert all(re.fullmatch(label_pattern, node.value) for node in blank_nodes) diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index c3c935a188..8da48fa8b3 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -1,7 +1,6 @@ """Tests for deterministic RDF serialization via pyoxigraph RDFC-1.0.""" import difflib -import inspect import os import re import subprocess @@ -235,8 +234,7 @@ def test_curie_in_literal_with_hash_not_treated_as_comment(): assert "ex:bar\\. more" in str(obj) -@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) -def test_sort_is_load_bearing(diff_stable: bool) -> None: +def test_sort_is_load_bearing(): """Output is byte-identical across subprocesses with different PYTHONHASHSEED values. RDFC-1.0 stabilizes blank-node labels, but pyoxigraph's ``Dataset`` iteration @@ -249,8 +247,6 @@ def test_sort_is_load_bearing(diff_stable: bool) -> None: """ program = textwrap.dedent( """ - import sys - from rdflib import BNode, Graph, Literal, URIRef from rdflib.namespace import RDF from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph @@ -266,7 +262,7 @@ def test_sort_is_load_bearing(diff_stable: bool) -> None: g.add((URIRef("http://example.com/x"), URIRef("http://example.com/has"), bn)) g.add((bn, URIRef("http://example.com/q"), Literal(f"bn_{i}"))) - print(canonicalize_rdf_graph(g, output_format="turtle", diff_stable=sys.argv[1] == "True"), end="") + print(canonicalize_rdf_graph(g, output_format="turtle"), end="") """ ) @@ -275,7 +271,7 @@ def run(seed: str) -> str: # wholesale drops SystemRoot on Windows, which breaks winsock init. env = {**os.environ, "PYTHONHASHSEED": seed} result = subprocess.run( - [sys.executable, "-c", program, str(diff_stable)], + [sys.executable, "-c", program], check=True, capture_output=True, text=True, @@ -540,9 +536,12 @@ def _changed_line_count(before: str, after: str) -> int: return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) -def test_diff_stable_is_opt_in() -> None: - """Diff-stable labels require an explicit request.""" - assert inspect.signature(canonicalize_rdf_graph).parameters["diff_stable"].default is False +def test_diff_stable_without_the_extra_names_the_extra(mock_missing_import) -> None: + """Requesting the opt-in path without diffable-rdf says how to install it.""" + mock_missing_import("diffable_rdf") + + with pytest.raises(ImportError, match=r"linkml-runtime\[diff-stable\]"): + canonicalize_rdf_graph(_make_graph_with_bnodes(), diff_stable=True) @pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) From 2208d4f0ad930dcb589799d71587b61fd45e0970 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 11:08:44 +0200 Subject: [PATCH 24/24] test(rdf): record the canonicalizer gaps against the extracted library Asserts each correctness property against both linkml's copy and diffable_rdf, marking whichever implementation does not hold it as a strict xfail, so the file is a ratchet in both directions. Nine gaps run one way, two of them silent data corruption. One ran the other way -- the library dropped @base on the degraded path -- and that was the last property blocking delegation. diffable-rdf 0.4.0 fixed it, so that case now passes on both sides and carries no mark. --- .../test_rdf_canonicalize_defects.py | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py new file mode 100644 index 0000000000..806be2e3eb --- /dev/null +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py @@ -0,0 +1,353 @@ +"""Correctness differences between linkml's RDF canonicalizer and the extracted library. + +``linkml_runtime.utils.rdf_canonicalize`` and +``diffable_rdf.canonicalize_rdf_graph`` are the same code: the module was +extracted into a standalone library at the maintainers' request +(linkml/linkml#3295). The two copies have since diverged, so each test here +asserts one correctness property against *both* implementations and marks the +one that does not hold it as a strict xfail. + +Every remaining gap runs one way -- the extracted copy received fixes the local +one did not -- and two of them are silent data corruption, where the output +parses cleanly and says something the input never said. That is worse than a +crash, because a generated artifact gets committed and reviewed on the +assumption that it means what the schema meant. + +One gap used to run the other way: the library dropped ``@base`` on the +degraded path. That was the last property blocking delegation, and +diffable-rdf 0.4.0 fixed it, so the case now passes on both sides and is kept +unmarked. Asserting both directions is what made the fix land where it +belongs. + +The xfails are strict, so this file is a ratchet in both directions: when a fix +lands on either side, its case starts passing and the suite fails until the +mark is removed. +""" + +import os +import subprocess +import sys +import textwrap +import warnings + +import diffable_rdf +import pytest +import rdflib +from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib.namespace import RDF + +from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph as linkml_canonicalize + +EX = Namespace("http://example.org/") + +_IMPLEMENTATIONS = ( + (linkml_canonicalize, "linkml"), + (diffable_rdf.canonicalize_rdf_graph, "diffable-rdf"), +) + + +def _impls(**known_failures: str): + """Parametrize over both implementations, xfailing the ones named. + + :param known_failures: implementation id (``linkml`` / ``diffable_rdf``) + mapped to why that implementation does not hold the property. + """ + params = [] + for fn, ident in _IMPLEMENTATIONS: + reason = known_failures.get(ident.replace("-", "_")) + marks = [pytest.mark.xfail(strict=True, reason=reason)] if reason else [] + params.append(pytest.param(fn, id=ident, marks=marks)) + return params + + +def _apply(fn, graph, output_format="turtle"): + """Call an implementation, ignoring the warnings both emit on the fallback path.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return fn(graph, output_format) + + +# -------------------------------------------------------------------------- +# Silent data corruption +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="passes graph.base to the serializer without checking it is safe to relativize against"), +) +def test_base_with_a_fragment_does_not_rewrite_every_iri(canonicalize): + """A base IRI ending in ``#`` must not change the graph's terms. + + Relativizing ``http://ex.org/d#a`` against base ``http://ex.org/d#`` gives + ``<#a>``, which is correct per RFC 3986 -- but rdflib's parser resolves a + fragment reference by concatenation and reads it back as + ``http://ex.org/d##a``. Every term of the graph silently changes, and the + output parses cleanly, so nothing reports it. + """ + graph = Graph(base="http://ex.org/d#") + graph.add((URIRef("http://ex.org/d#a"), EX.p, Literal("v"))) + + round_tripped = Graph() + round_tripped.parse(data=_apply(canonicalize, graph), format="turtle") + + assert {str(s) for s in round_tripped.subjects()} == {"http://ex.org/d#a"} + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="rdflib's collection syntax materializes a shared list tail once per referencing list"), +) +def test_a_shared_rdf_list_tail_is_not_duplicated(canonicalize): + """Two lists sharing a tail must not gain triples on the way out. + + rdflib's Turtle writer renders ``( ... )`` collection syntax per list, so a + tail referenced from two lists is written twice as two separate blank + nodes. The result asserts more than the input did. + """ + graph = Graph() + graph.bind("ex", EX) + tail = BNode() + graph.add((tail, RDF.first, Literal("shared"))) + graph.add((tail, RDF.rest, RDF.nil)) + for name in ("l1", "l2"): + head = BNode() + graph.add((EX[name], EX.list, head)) + graph.add((head, RDF.first, Literal(name))) + graph.add((head, RDF.rest, tail)) + # A relative IRI is non-standard RDF, so pyoxigraph refuses the graph and + # both implementations degrade to rdflib -- the path this defect lives on. + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + round_tripped = Graph() + round_tripped.parse(data=_apply(canonicalize, graph), format="turtle") + + assert len(round_tripped) == len(graph) + + +# -------------------------------------------------------------------------- +# Output that no parser will read +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="the rdflib fallback writes N-Triples for a graph pyoxigraph already refused"), +) +def test_nt_output_either_parses_or_refuses(canonicalize): + """N-Triples admits only absolute IRIs, so a relative one must not be written. + + The fallback is taken *because* the graph holds a term pyoxigraph rejected. + Writing it out anyway produces a file that fails on line 1. Refusing with a + message naming the term and a format that can carry the graph is the fix; + returning unreadable text is not. + """ + graph = Graph() + graph.add((URIRef("testing"), EX.p, Literal("v"))) + + try: + serialized = _apply(canonicalize, graph, "nt") + except ValueError: + return # refused, with an explanation -- the correct outcome + + rdflib.Graph().parse(data=serialized, format="nt") + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="sorts fallback output with str.splitlines(), which breaks on U+2028 and five other characters"), +) +def test_a_unicode_line_separator_in_a_literal_survives(canonicalize): + """Sorting N-Triples lines must split on newlines only. + + N-Triples permits U+2028 raw inside a quoted literal, but + ``str.splitlines()`` breaks on it as well as on U+2029, U+0085, U+000B, + U+000C and U+001C-1E. One statement becomes two lines, the halves sort + independently, the separator is rewritten as a newline, and the document + no longer parses. + """ + graph = Graph() + graph.add((EX.s, EX.p, Literal("a\u2028b"))) + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + try: + serialized = _apply(canonicalize, graph, "nt") + except ValueError: + return # refused because of the relative IRI, before the sort + + assert "\u2028" in serialized + rdflib.Graph().parse(data=serialized, format="nt") + + +# -------------------------------------------------------------------------- +# Non-determinism, which is the property the module exists to provide +# -------------------------------------------------------------------------- + +_ACROSS_PROCESSES = textwrap.dedent( + """ + import sys, warnings + warnings.simplefilter("ignore") + from rdflib import BNode, Graph, Literal, Namespace, URIRef + from rdflib.namespace import RDF + + module, output_format, force_fallback = sys.argv[1], sys.argv[2], sys.argv[3] == "1" + if module == "linkml": + from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + else: + from diffable_rdf import canonicalize_rdf_graph + + EX = Namespace("http://example.org/") + g = Graph() + if force_fallback: + g.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + for i in range(12): + b = BNode() + g.add((EX["s%02d" % i], EX.child, b)) + g.add((b, EX.k, Literal("v%d" % i))) + g.add((b, RDF.type, EX.T)) + for host in "abcdefgh": + g.add((URIRef("http://%s.example/s" % host), + URIRef("http://%s.example/p" % host), + URIRef("http://%s.example/o" % host))) + sys.stdout.write(canonicalize_rdf_graph(g, output_format)) + """ +) + + +def _outputs_across_processes(module, output_format, force_fallback): + """Serialize the same graph in four processes with different hash seeds.""" + outputs = set() + for seed in ("0", "1", "12345", "999"): + completed = subprocess.run( + [sys.executable, "-c", _ACROSS_PROCESSES, module, output_format, "1" if force_fallback else "0"], + capture_output=True, + text=True, + encoding="utf-8", + env={**os.environ, "PYTHONHASHSEED": seed}, + check=False, + ) + if completed.returncode != 0: + pytest.fail(f"{module}/{output_format} failed:\n{completed.stderr[-2000:]}") + outputs.add(completed.stdout) + return outputs + + +_XML_TRAVERSAL = "degraded RDF/XML is ordered by rdflib's graph traversal, which follows set iteration order" +_NS_NAMES = "auto-generated ns1/ns2 prefix names are allocated in traversal order" +_JSONLD_UNMAPPED = "json-ld is absent from the format map, so it falls through to rdflib with no determinism guarantee" + + +@pytest.mark.parametrize( + ("module", "output_format", "force_fallback"), + [ + pytest.param( + "linkml", "xml", True, id="linkml-xml-fallback", marks=pytest.mark.xfail(strict=True, reason=_XML_TRAVERSAL) + ), + pytest.param("diffable_rdf", "xml", True, id="diffable-rdf-xml-fallback"), + pytest.param( + "linkml", + "turtle", + True, + id="linkml-turtle-fallback", + marks=pytest.mark.xfail(strict=True, reason=_NS_NAMES), + ), + pytest.param("diffable_rdf", "turtle", True, id="diffable-rdf-turtle-fallback"), + pytest.param( + "linkml", + "json-ld", + False, + id="linkml-jsonld", + marks=pytest.mark.xfail(strict=True, reason=_JSONLD_UNMAPPED), + ), + pytest.param("diffable_rdf", "json-ld", False, id="diffable-rdf-jsonld"), + ], +) +def test_output_is_byte_identical_across_processes(module, output_format, force_fallback): + """The same graph must serialize to the same bytes in any process. + + Three separate causes break this in the local copy. Degraded RDF/XML is + ordered by rdflib's own graph traversal, which follows set iteration order. + Auto-generated ``ns1``/``ns2`` prefix names are allocated in traversal order + too, so the *names* move even when the triples do not. And ``json-ld`` is + not in the format map at all, so it falls through to rdflib's serializer, + which carries no determinism guarantee. + """ + assert len(_outputs_across_processes(module, output_format, force_fallback)) == 1 + + +# -------------------------------------------------------------------------- +# Interface +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="returns the serializer's own trailing whitespace, which differs by format"), +) +def test_every_format_ends_with_exactly_one_newline(canonicalize): + """A committed artifact should not depend on which format wrote it. + + Passing through whatever the serializer produced gives no trailing newline + for RDF/XML and one for Turtle, so POSIX text tools disagree about whether + the file has a last line. + """ + counts = {} + for output_format in ("turtle", "nt", "xml", "trig", "n3"): + graph = Graph() + graph.bind("ex", EX) + graph.add((EX.s, EX.p, Literal("v"))) + serialized = _apply(canonicalize, graph, output_format) + counts[output_format] = len(serialized) - len(serialized.rstrip("\n")) + + assert counts == dict.fromkeys(counts, 1) + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="Dataset is a Graph subclass, so the type check accepts it and the named graphs are flattened"), +) +def test_a_dataset_is_refused_rather_than_partly_serialized(canonicalize): + """A Dataset is a Graph subclass, so it is accepted and quietly flattened. + + Every named graph collapses into one document and the graph names are + dropped, which is a different dataset. The caller asked for something this + function cannot express and should be told so. + """ + dataset = rdflib.Dataset() + dataset.graph(URIRef("http://ex/g1")).add((EX.a, EX.p, Literal("in-g1"))) + dataset.graph(URIRef("http://ex/g2")).add((EX.b, EX.p, Literal("in-g2"))) + + with pytest.raises(TypeError): + _apply(canonicalize, dataset) + + +# -------------------------------------------------------------------------- +# A property both implementations now hold +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_base_survives_the_degraded_path(canonicalize): + """``@base`` must not disappear just because pyoxigraph refused the graph. + + ``rdflib_dumper.dumps(..., prefix_map={"@base": ...})`` is a supported way + to ask for a document whose IRIs are written relative to a base, and the + metamodel routinely produces graphs that fall back (a bare ``status: + testing`` on a ``uriorcurie`` slot serializes to the relative ````, + which pyoxigraph rejects). Losing the directive on exactly that path means + the feature works only for graphs that never needed the fallback. + + This ran the other way until diffable-rdf 0.4.0, which was the last + property blocking delegation. The case is kept because it is the one the + two implementations reach differently: linkml passes ``graph.base`` to the + serializer unconditionally, which is why it also fails + :func:`test_base_with_a_fragment_does_not_rewrite_every_iri` above, while + the library keeps the base only after confirming that re-reading the + result still yields every absolute IRI of the source. + """ + graph = Graph(base="http://example.org/default/") + graph.bind("ex", EX) + graph.add((EX.s, EX.p, Literal("v"))) + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + assert "@base ." in _apply(canonicalize, graph)