From 4e6a03dbb9a71dd3c40cd5bd1ffb3df01efd8c7e Mon Sep 17 00:00:00 2001 From: noelmcloughlin Date: Thu, 17 Sep 2026 11:33:05 +0100 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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 16/18] 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 17/18] 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 bae4a433122e7df38c128fe8210e95833ba21766 Mon Sep 17 00:00:00 2001 From: Rayene Messaoud Date: Fri, 11 Sep 2026 14:45:34 +0200 Subject: [PATCH 18/18] feat(gen-shacl): translate presence-implies-value rules to SHACL-SPARQL The rules-to-SHACL-SPARQL converter added in #3451 recognised a single named pattern. This adds the presence-implies-value pattern: a precondition asserting `value_presence: PRESENT` on one slot, and a postcondition constraining another slot with `equals_string` or `equals_string_in`. It reads as "if the guard slot is present, the target slot must be present and hold one of the allowed values", and generalises the existing boolean guard to arbitrary enum values. The boolean guard is now gated on the target slot's range actually being `boolean`. Without that gate a slot of range `string` carrying `equals_string: "true"` was translated as a boolean comparison, which does not match the string `"true"` in the data and so flagged conforming instances as violations. String-ranged slots now fall through to the presence-implies-value pattern and compare as strings. Pattern matching is exact: each converter requires its conditions to set precisely the operators it translates. A rule whose conditions carry anything further -- extra scalar operators, or expression-level any_of / all_of / none_of / exactly_one_of -- is skipped rather than partially translated, since dropping a term would either widen the precondition (false positives) or weaken the postcondition (false negatives). Slot resolution goes through induced slots so that `slot_usage` overrides, `slot_uri` overrides and alias-form keys resolve to the same IRI that `sh:path` emits. Co-authored-by: jdsika --- .../linkml/src/linkml/generators/shaclgen.py | 319 +++++- tests/linkml/test_generators/test_shaclgen.py | 925 ++++++++++++++++++ 2 files changed, 1204 insertions(+), 40 deletions(-) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 4731b9f0b8..325cd68c09 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -147,11 +147,15 @@ class ShaclGenerator(Generator): When ``True`` (default), recognised rule patterns are translated into SHACL-SPARQL constraints (``sh:SPARQLConstraint``) on the corresponding - ``sh:NodeShape``. Currently two patterns are recognised: + ``sh:NodeShape``. Currently three patterns are recognised: * *Boolean guard* — a precondition with ``value_presence: PRESENT`` on a value slot and a postcondition with ``equals_string: "true"`` on a boolean flag slot. + * *Presence implies value* — a precondition with ``value_presence: PRESENT`` + on a value slot and a postcondition with ``equals_string`` or + ``equals_string_in`` on a (typically enum-valued) target slot. This + generalises the boolean guard to arbitrary required values. * *Exclusive value* — a precondition with ``equals_string`` on a slot and a postcondition with ``maximum_cardinality`` on the *same* slot. @@ -429,12 +433,22 @@ def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: ``value_presence: PRESENT`` on a value slot and a *postcondition* with ``equals_string: "true"`` on a boolean flag slot. + * **Presence implies value** — a *precondition* with + ``value_presence: PRESENT`` on a value slot and a *postcondition* + with ``equals_string`` or ``equals_string_in`` on a target slot. + Enforces that when the value slot is present, the target slot must + be present and hold one of the allowed values (generalises the + boolean guard to enum-valued targets). + * **Exclusive value** — a *precondition* with ``equals_string`` on a slot and a *postcondition* with ``maximum_cardinality`` on the *same* slot. Enforces that when a specific value is present in a multivalued slot, the total number of values must not exceed the given cardinality (typically 1 for mutual exclusion). + Operator combinations outside these named patterns are not translated; + the rule is skipped rather than partially represented. + See `W3C SHACL §5 `_. """ if not cls.rules: @@ -462,11 +476,11 @@ def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: cls.name, ) - if getattr(rule, "elseconditions", None): + if getattr(rule, "elseconditions", None) is not None: logger.warning( "Rule in class %r has elseconditions; " - "only the forward (if/then) branch is emitted as sh:sparql. " - "The else branch cannot be represented in SHACL-SPARQL.", + "SHACL-SPARQL generation emits the forward (if/then) direction only. " + "The else branch is not enforced.", cls.name, ) @@ -489,22 +503,137 @@ def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: g.add((constraint, SH.select, Literal(sparql_query))) + # Fields on a slot condition / class expression that carry no constraint + # semantics: they never change which instances satisfy the condition, so + # they are ignored by the operator accounting below. Anything set on a + # condition that is neither here nor explicitly translated by a converter + # makes the rule untranslatable — the converters must SKIP such a rule + # rather than emit a query that silently drops a conjunct (which would + # widen the trigger or narrow the check: a mis-translation, not a skip). + _NON_OPERATOR_FIELDS = frozenset( + { + "name", + "description", + "title", + "deprecated", + "todos", + "notes", + "comments", + "examples", + "in_subset", + "from_schema", + "imported_from", + "source", + "in_language", + "see_also", + "deprecated_element_has_exact_replacement", + "deprecated_element_has_possible_replacement", + "aliases", + "structured_aliases", + "local_names", + "mappings", + "exact_mappings", + "close_mappings", + "related_mappings", + "narrow_mappings", + "broad_mappings", + "created_by", + "contributors", + "created_on", + "last_updated_on", + "modified_by", + "status", + "rank", + "categories", + "keywords", + "extensions", + "annotations", + "alt_descriptions", + "id_prefixes", + "id_prefixes_are_closed", + "definition_uri", + "conforms_to", + "implements", + "instantiates", + } + ) + + @classmethod + def _set_operator_fields(cls, cond) -> set[str]: + """Return the names of the constraint-bearing fields actually set on a + rule condition or class expression. + + A field counts as *set* when it is not ``None`` and not an empty + collection (SchemaView materialises unset multivalued fields as empty + lists / dicts). Scalars are never judged by truthiness, so legitimate + falsy constraints such as ``minimum_value: 0`` or + ``equals_string: ""`` still count as set. Metadata fields + (:data:`_NON_OPERATOR_FIELDS`) are excluded. + + The converters compare this set against the exact operator set they + translate and skip the rule on any mismatch, so an unrecognised (or + future-metamodel) operator can never be silently dropped. + """ + fields: set[str] = set() + for name, value in vars(cond).items(): + if name.startswith("_") or name in cls._NON_OPERATOR_FIELDS: + continue + if value is None: + continue + if isinstance(value, list | dict) and not value: + continue + if isinstance(value, JsonObj) and not as_dict(value): + continue + fields.add(name) + return fields + + def _rule_slot(self, sv, slot_name: str, cls: ClassDefinition): + """Resolve a rule condition's slot key to the slot it names, or ``None`` + when no such slot exists. + + Resolution order mirrors ``sh:path`` in the main slot loop: the induced + (class-specific) slot when the key names one of the class's slots, then + the underscored alias form (a rule key ``my_slot`` for a slot named + ``my slot`` — SchemaView normalises names the same way elsewhere), then + the base slot. Callers treat ``None`` as *unknown slot* and skip the + rule rather than fabricating a predicate no shape uses. + """ + class_slot_names = sv.class_slots(cls.name) + if slot_name in class_slot_names: + return sv.induced_slot(slot_name, cls.name) + canonical = next((s for s in class_slot_names if underscore(s) == underscore(slot_name)), None) + if canonical is not None: + return sv.induced_slot(canonical, cls.name) + return sv.get_slot(slot_name) + def _rule_to_sparql(self, sv, cls: ClassDefinition, rule) -> str | None: """Convert a ``ClassRule`` to a SPARQL SELECT query string. Returns ``None`` when the rule does not match any supported pattern. + Each pattern requires its conditions to set **exactly** the operators + it translates; a rule whose pre/postconditions carry anything more + (extra scalar operators, expression-level ``any_of``/``all_of``/ + ``none_of``/``exactly_one_of``, ...) is skipped rather than partially + translated. """ pre = getattr(rule, "preconditions", None) post = getattr(rule, "postconditions", None) if not pre or not post: return None - pre_slots = getattr(pre, "slot_conditions", None) or {} - post_slots = getattr(post, "slot_conditions", None) or {} + # Expression-level exactness: only a plain conjunction of slot + # conditions is translatable. An any_of/all_of/none_of/exactly_one_of + # branch cannot be honoured by any converter below; dropping it would + # widen the precondition (false positives) or weaken the postcondition + # (false negatives), so the whole rule is skipped. + if self._set_operator_fields(pre) != {"slot_conditions"}: + return None + if self._set_operator_fields(post) != {"slot_conditions"}: + return None + + pre_slots = pre.slot_conditions or {} + post_slots = post.slot_conditions or {} - # Pattern: boolean guard - # preconditions: exactly one slot with value_presence PRESENT - # postconditions: exactly one slot with equals_string "true" if len(pre_slots) == 1 and len(post_slots) == 1: pre_slot_name = next(iter(pre_slots)) post_slot_name = next(iter(post_slots)) @@ -512,31 +641,83 @@ def _rule_to_sparql(self, sv, cls: ClassDefinition, rule) -> str | None: pre_cond = pre_slots[pre_slot_name] post_cond = post_slots[post_slot_name] - # Note: PresenceEnum.PRESENT is a PermissibleValue, but parsed schemas - # return PresenceEnum instances — wrapping ensures type-compatible comparison. - is_value_present = getattr(pre_cond, "value_presence", None) == PresenceEnum(PresenceEnum.PRESENT) - is_flag_true = getattr(post_cond, "equals_string", None) == "true" + pre_ops = self._set_operator_fields(pre_cond) + post_ops = self._set_operator_fields(post_cond) - if is_value_present and is_flag_true: + is_value_present = pre_ops == {"value_presence"} and pre_cond.value_presence == PresenceEnum( + PresenceEnum.PRESENT + ) + + # Pattern: boolean guard + # preconditions: exactly one slot with (only) value_presence PRESENT + # postconditions: exactly one boolean-range slot with (only) + # equals_string "true". The range gate matters: on a non-boolean + # slot the string "true" must be compared as a string, which is + # the presence-implies-value pattern below — without the gate the + # boolean comparison mistranslates and flags conforming data. + if ( + is_value_present + and post_ops == {"equals_string"} + and post_cond.equals_string == "true" + and getattr(self._rule_slot(sv, post_slot_name, cls), "range", None) == "boolean" + ): return self._build_boolean_guard_sparql(sv, cls, post_slot_name, pre_slot_name) + # Pattern: presence implies value (enum guard) + # preconditions: value slot with (only) value_presence PRESENT + # postconditions: target slot with (only) equals_string or (only) + # equals_string_in. + # Semantics: "If the value slot is present, the target slot must be + # present and hold one of the allowed values." Generalises the + # boolean guard (equals_string "true") to arbitrary enum values. + if is_value_present and post_ops in ({"equals_string"}, {"equals_string_in"}): + if post_ops == {"equals_string_in"}: + allowed = list(post_cond.equals_string_in) + else: + allowed = [post_cond.equals_string] + return self._build_presence_implies_value_sparql(sv, cls, pre_slot_name, post_slot_name, allowed) + # Pattern: exclusive value - # preconditions: slot X has equals_string (a specific enum value) - # postconditions: same slot X has maximum_cardinality N + # preconditions: slot X with (only) equals_string (a specific enum value) + # postconditions: same slot X with (only) maximum_cardinality N # Semantics: "If value V is present in slot X, then X has at most N values." - pre_equals = getattr(pre_cond, "equals_string", None) - post_max_card = getattr(post_cond, "maximum_cardinality", None) - - if pre_equals is not None and post_max_card is not None and pre_slot_name == post_slot_name: - return self._build_exclusive_value_sparql(sv, cls, pre_slot_name, pre_equals, int(post_max_card)) + if pre_ops == {"equals_string"} and post_ops == {"maximum_cardinality"} and pre_slot_name == post_slot_name: + return self._build_exclusive_value_sparql( + sv, cls, pre_slot_name, pre_cond.equals_string, int(post_cond.maximum_cardinality) + ) + # Fallback: a small compositional builder for operator combinations not return None - def _build_boolean_guard_sparql(self, sv, cls: ClassDefinition, flag_slot_name: str, value_slot_name: str) -> str: + @staticmethod + def _sparql_string_literal(value: str) -> str: + """Render *value* as a double-quoted SPARQL string literal, escaping the + characters the grammar forbids raw. + + ``equals_string`` / permissible-value names are schema-controlled but + may legitimately contain a double quote, backslash, or newline; without + escaping these would break the ``sh:select`` query (or allow SPARQL + injection). See `SPARQL 1.1 §19.7 escape sequences + `_. + """ + escaped = ( + str(value) + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + return f'"{escaped}"' + + def _build_boolean_guard_sparql( + self, sv, cls: ClassDefinition, flag_slot_name: str, value_slot_name: str + ) -> str | None: """Build a SPARQL SELECT query for the boolean-guard pattern. The query detects violations where the value property is present - but the boolean flag is absent or not ``true``. + but the boolean flag is absent or not ``true``. Returns ``None`` + (rule skipped) when either slot name resolves to no slot. Conforms to `SHACL §5.3.1 `_: @@ -544,6 +725,8 @@ def _build_boolean_guard_sparql(self, sv, cls: ClassDefinition, flag_slot_name: """ flag_uri = self._slot_uri(sv, flag_slot_name, cls) value_uri = self._slot_uri(sv, value_slot_name, cls) + if flag_uri is None or value_uri is None: + return None return ( f"SELECT $this WHERE {{\n" @@ -556,6 +739,44 @@ def _build_boolean_guard_sparql(self, sv, cls: ClassDefinition, flag_slot_name: f"}}" ) + def _build_presence_implies_value_sparql( + self, + sv, + cls: ClassDefinition, + value_slot_name: str, + target_slot_name: str, + allowed_values: list[str], + ) -> str | None: + """Build a SPARQL SELECT query for the presence-implies-value pattern. + + Detects violations where the *value slot* is present but the *target + slot* is absent or holds a value outside the allowed set. This + generalises the boolean-guard pattern to enum-valued targets: it + supports a single required value (``equals_string``) or a set of + acceptable values (``equals_string_in``). + + Each allowed value is resolved via the target slot's enum ``meaning`` + to a full IRI; values without a ``meaning`` (or non-enum targets) fall + back to a plain string literal. + + Conforms to `SHACL §5.3.1 + `_: + ``$this`` is pre-bound to each focus node. + """ + value_uri = self._slot_uri(sv, value_slot_name, cls) + target_uri = self._slot_uri(sv, target_slot_name, cls) + if value_uri is None or target_uri is None: + return None + refs = ", ".join(self._resolve_enum_value_ref(sv, target_slot_name, v, cls) for v in allowed_values) + + return ( + f"SELECT $this WHERE {{\n" + f" $this <{value_uri}> ?value .\n" + f" OPTIONAL {{ $this <{target_uri}> ?target . }}\n" + f" FILTER ( !BOUND(?target) || ?target NOT IN ({refs}) )\n" + f"}}" + ) + def _build_exclusive_value_sparql( self, sv, @@ -583,7 +804,9 @@ def _build_exclusive_value_sparql( ``$this`` is pre-bound to each focus node. """ slot_uri = self._slot_uri(sv, slot_name, cls) - value_ref = self._resolve_enum_value_ref(sv, slot_name, value_name) + if slot_uri is None: + return None + value_ref = self._resolve_enum_value_ref(sv, slot_name, value_name, cls) if max_card == 1: return ( @@ -606,15 +829,20 @@ def _build_exclusive_value_sparql( f"}}" ) - def _resolve_enum_value_ref(self, sv, slot_name: str, value_name: str) -> str: + def _resolve_enum_value_ref(self, sv, slot_name: str, value_name: str, cls: ClassDefinition | None = None) -> str: """Resolve an enum value name to a SPARQL term (IRI or literal). Looks up the slot's range as an enum, finds the permissible value matching *value_name*, and returns its ``meaning`` as a full IRI - wrapped in angle brackets. Falls back to a quoted literal if the - slot is not an enum or the value lacks a ``meaning``. + wrapped in angle brackets. Falls back to an escaped quoted literal if + the slot is not an enum or the value lacks a ``meaning``. + + When *cls* is given and the slot is declared on it, the slot is resolved + in the class's induced context, so a range narrowed via ``slot_usage`` + (a class-specific enum) selects the correct permissible values instead + of the base slot's enum. """ - slot = sv.get_slot(slot_name) + slot = self._rule_slot(sv, slot_name, cls) if cls is not None else sv.get_slot(slot_name) if slot: range_name = slot.range if range_name and range_name in sv.all_enums(): @@ -623,17 +851,27 @@ def _resolve_enum_value_ref(self, sv, slot_name: str, value_name: str) -> str: if pv and pv.meaning: iri = sv.expand_curie(pv.meaning) return f"<{iri}>" - return f'"{value_name}"' - - def _slot_uri(self, sv, slot_name: str, cls: ClassDefinition) -> str: - """Resolve a slot name to a full IRI string for use in SPARQL queries. - - Mirrors the resolution logic used for ``sh:path`` in the main slot loop: - prefer ``sv.get_uri()`` for slots registered in the schema map, fall - back to ``default_prefix:underscored_name``. + return self._sparql_string_literal(value_name) + + def _slot_uri(self, sv, slot_name: str, cls: ClassDefinition) -> str | None: + """Resolve a slot name to a full IRI string for use in SPARQL queries, + or ``None`` when the name resolves to no slot at all (callers then skip + the rule). + + Mirrors the resolution logic used for ``sh:path`` in the main slot loop, + including the **induced** (class-specific) slot: a ``slot_usage`` + override of ``slot_uri`` must yield the same IRI as ``sh:path``. + Otherwise the SPARQL body would query a property the data never uses and + the constraint would silently never fire (a false negative). A slot + that resolves but is not registered in the schema's element map falls + back to ``default_prefix:underscored_name``, again matching ``sh:path``; + an *unknown* name must NOT take that fallback — it would fabricate a + predicate no shape uses and emit a vacuous constraint. """ - slot = sv.get_slot(slot_name) - if slot and slot_name in sv.element_by_schema_map(): + slot = self._rule_slot(sv, slot_name, cls) + if slot is None: + return None + if slot.name in sv.element_by_schema_map(): return sv.get_uri(slot, expand=True) pfx = sv.schema.default_prefix return sv.expand_curie(f"{pfx}:{underscore(slot_name)}") @@ -924,8 +1162,9 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: show_default=True, help=( "Emit sh:sparql constraints from LinkML rules: blocks. " - "When enabled (default), recognised rule patterns (e.g. boolean-guard) " - "are translated into SHACL-SPARQL constraints on the corresponding " + "When enabled (default), recognised rule patterns (boolean-guard, " + "presence-implies-value, exclusive-value) are translated into " + "SHACL-SPARQL constraints on the corresponding " "sh:NodeShape. Use --no-emit-rules to suppress rule generation." ), ) diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index f367de97e7..00491344cb 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -2833,3 +2833,928 @@ def test_shacl_modular_schema_with_reused_attribute_name(tmp_path) -> None: 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 + + +# =========================================================================== +# Presence-implies-value pattern tests (enum guard) +# =========================================================================== +# +# The "presence implies value" pattern generalises the boolean guard to +# enum-valued targets. It translates a LinkML rule where: +# - preconditions: a value slot has value_presence: PRESENT +# - postconditions: a target slot has equals_string (single required value) +# or equals_string_in (a set of acceptable values) +# +# Semantics: "If the value slot is present, the target slot must be present +# and hold one of the allowed values." The motivating use case is the aiSim +# environment model, e.g. "if texture_sky_color is set, sky_model must be +# TextureSky" and "if overcast_sky_illuminance is set, sky_model must be an +# overcast model". +# +# References: +# - W3C SHACL §5 +# - W3C SHACL §5.3.1 +# =========================================================================== + +_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML = """ +id: https://example.org/presence-implies-value +name: presence_implies_value_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/presence-implies-value/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +enums: + SkyModelEnum: + permissible_values: + ClearSky: + meaning: ex:ClearSky + OvercastSky: + meaning: ex:OvercastSky + MeasuredOvercastSky: + meaning: ex:MeasuredOvercastSky + TextureSky: + meaning: ex:TextureSky + + ModeEnum: + permissible_values: + Auto: + description: Automatic mode (no meaning IRI). + Manual: + description: Manual mode (no meaning IRI). + +slots: + sky_model: + range: SkyModelEnum + slot_uri: ex:sky_model + texture_sky_color: + range: string + slot_uri: ex:texture_sky_color + overcast_sky_illuminance: + range: float + slot_uri: ex:overcast_sky_illuminance + mode: + range: ModeEnum + slot_uri: ex:mode + manual_value: + range: decimal + slot_uri: ex:manual_value + +classes: + Weather: + class_uri: ex:Weather + slots: + - sky_model + - texture_sky_color + - overcast_sky_illuminance + rules: + - description: If texture_sky_color is provided, sky_model must be TextureSky. + preconditions: + slot_conditions: + texture_sky_color: + value_presence: PRESENT + postconditions: + slot_conditions: + sky_model: + equals_string: "TextureSky" + - description: If overcast_sky_illuminance is provided, sky_model must be an overcast model. + preconditions: + slot_conditions: + overcast_sky_illuminance: + value_presence: PRESENT + postconditions: + slot_conditions: + sky_model: + equals_string_in: + - OvercastSky + - MeasuredOvercastSky + + Device: + class_uri: ex:Device + slots: + - mode + - manual_value + rules: + - description: If manual_value is provided, mode must be Manual (literal fallback). + preconditions: + slot_conditions: + manual_value: + value_presence: PRESENT + postconditions: + slot_conditions: + mode: + equals_string: "Manual" +""" + +EX_PIV = rdflib.Namespace("https://example.org/presence-implies-value/") + + +def test_presence_implies_value_generates_sparql(): + """Presence-implies-value rules produce sh:sparql constraints on the NodeShape.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + shape = EX_PIV.Weather + sparql_nodes = list(g.objects(shape, SH.sparql)) + assert len(sparql_nodes) == 2, f"Expected 2 sh:sparql constraints, got {len(sparql_nodes)}" + + for node in sparql_nodes: + assert (node, RDF.type, SH.SPARQLConstraint) in g + selects = list(g.objects(node, SH.select)) + assert len(selects) == 1, "Each constraint must have exactly one sh:select" + query = str(selects[0]) + assert "$this" in query, "SPARQL must use $this pre-bound variable" + assert "NOT IN" in query, "presence-implies-value SPARQL must use NOT IN membership test" + assert "FILTER" in query, "SPARQL must have a FILTER clause" + + +def test_presence_implies_value_single_uses_enum_iri(): + """A single equals_string target resolves to the enum meaning IRI.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + shape = EX_PIV.Weather + sparql_nodes = list(g.objects(shape, SH.sparql)) + queries = [str(list(g.objects(n, SH.select))[0]) for n in sparql_nodes] + + texture_query = [q for q in queries if "texture_sky_color" in q] + assert len(texture_query) == 1, "Expected exactly one texture_sky_color rule" + query = texture_query[0] + + # value slot and target slot URIs both present + assert str(EX_PIV.texture_sky_color) in query + assert str(EX_PIV.sky_model) in query + # target value resolves to the TextureSky meaning IRI in angle brackets + assert f"<{EX_PIV.TextureSky}>" in query, f"Expected TextureSky IRI, got:\n{query}" + + +def test_presence_implies_value_set_uses_all_iris(): + """equals_string_in resolves every allowed value to its enum meaning IRI.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + shape = EX_PIV.Weather + sparql_nodes = list(g.objects(shape, SH.sparql)) + queries = [str(list(g.objects(n, SH.select))[0]) for n in sparql_nodes] + + overcast_query = [q for q in queries if "overcast_sky_illuminance" in q] + assert len(overcast_query) == 1, "Expected exactly one overcast rule" + query = overcast_query[0] + + assert f"<{EX_PIV.OvercastSky}>" in query, f"Expected OvercastSky IRI, got:\n{query}" + assert f"<{EX_PIV.MeasuredOvercastSky}>" in query, f"Expected MeasuredOvercastSky IRI, got:\n{query}" + + +def test_presence_implies_value_no_meaning_falls_back_to_literal(): + """When the target enum value lacks a meaning IRI, it is compared as a literal.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + shape = EX_PIV.Device + sparql_nodes = list(g.objects(shape, SH.sparql)) + assert len(sparql_nodes) == 1 + + query = str(list(g.objects(sparql_nodes[0], SH.select))[0]) + assert '"Manual"' in query, f"No-meaning enum should use literal '\"Manual\"', got:\n{query}" + assert f"<{EX_PIV}Manual>" not in query, "Should not emit as IRI when meaning is absent" + + +def test_presence_implies_value_message_from_description(): + """Rule description is emitted as sh:message on the SPARQLConstraint.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + shape = EX_PIV.Weather + sparql_nodes = list(g.objects(shape, SH.sparql)) + messages = [str(m) for node in sparql_nodes for m in g.objects(node, SH.message)] + + assert any("sky_model must be TextureSky" in m for m in messages), ( + f"Expected message about TextureSky, got: {messages}" + ) + + +def test_presence_implies_value_sparql_syntax_valid(): + """Generated SPARQL for presence-implies-value rules must be syntactically valid.""" + from rdflib.plugins.sparql import prepareQuery + + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + for shape in (EX_PIV.Weather, EX_PIV.Device): + sparql_nodes = list(g.objects(shape, SH.sparql)) + for node in sparql_nodes: + query_text = str(list(g.objects(node, SH.select))[0]) + prepareQuery(query_text) + + +def test_presence_implies_value_pyshacl_end_to_end(): + """End-to-end: pyshacl passes conforming instances and flags violations.""" + import pyshacl + + shacl_ttl = ShaclGenerator(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML, mergeimports=False, emit_rules=True).serialize() + + # Conforming: guarded slots paired with an allowed sky_model; and an + # unguarded instance (no texture/overcast) is unaffected by the rules. + conforming_data = """ + @prefix ex: . + @prefix xsd: . + + ex:wTexture a ex:Weather ; + ex:texture_sky_color "0,0,0" ; + ex:sky_model ex:TextureSky . + + ex:wOvercast a ex:Weather ; + ex:overcast_sky_illuminance "5000.0"^^xsd:float ; + ex:sky_model ex:OvercastSky . + + ex:wMeasured a ex:Weather ; + ex:overcast_sky_illuminance "4200.0"^^xsd:float ; + ex:sky_model ex:MeasuredOvercastSky . + + ex:wClear a ex:Weather ; + ex:sky_model ex:ClearSky . + """ + + conforms, _, results_text = pyshacl.validate( + data_graph=conforming_data, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + ) + assert conforms, f"Conforming instances should pass SHACL validation:\n{results_text}" + + # Violating: texture_sky_color present but sky_model is ClearSky (not TextureSky). + violating_wrong_value = """ + @prefix ex: . + + ex:wBad a ex:Weather ; + ex:texture_sky_color "0,0,0" ; + ex:sky_model ex:ClearSky . + """ + conforms, _, results_text = pyshacl.validate( + data_graph=violating_wrong_value, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + ) + assert not conforms, f"Wrong-value instance should fail SHACL validation:\n{results_text}" + + # Violating: overcast_sky_illuminance present but sky_model is TextureSky + # (not in the allowed overcast set). + violating_not_in_set = """ + @prefix ex: . + @prefix xsd: . + + ex:wBad2 a ex:Weather ; + ex:overcast_sky_illuminance "5000.0"^^xsd:float ; + ex:sky_model ex:TextureSky . + """ + conforms, _, results_text = pyshacl.validate( + data_graph=violating_not_in_set, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + ) + assert not conforms, f"Not-in-set instance should fail SHACL validation:\n{results_text}" + + # Violating: texture_sky_color present but sky_model entirely absent. + violating_missing_target = """ + @prefix ex: . + + ex:wBad3 a ex:Weather ; + ex:texture_sky_color "0,0,0" . + """ + conforms, _, results_text = pyshacl.validate( + data_graph=violating_missing_target, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + ) + assert not conforms, f"Missing-target instance should fail SHACL validation:\n{results_text}" + + +# =========================================================================== +# Compositional fallback: conditional-required pattern (M1) +# =========================================================================== +# +# Rule shape: +# - preconditions: slot X has equals_string V +# - postconditions: slot Y has required: true +# +# Semantics: "If X = V, then Y must be present." Emitted as an +# sh:SPARQLConstraint whose SELECT matches focus nodes where the precondition +# holds but the required slot is absent (FILTER NOT EXISTS). +# =========================================================================== + + +# =========================================================================== +# Compositional fallback: conditional-absent pattern (M2) +# =========================================================================== +# +# Rule shape: +# - preconditions: slot X has equals_string V +# - postconditions: slot Y has value_presence: ABSENT +# +# Semantics: "If X = V, then Y must NOT be present" (inapplicable slot). +# Emitted as an sh:SPARQLConstraint whose SELECT matches focus nodes where the +# precondition holds and the forbidden slot is present. +# =========================================================================== + + +# =========================================================================== +# Compositional fallback: numeric threshold precondition (M3) +# =========================================================================== +# +# Rule shape: +# - preconditions: slot X has maximum_value N (or minimum_value) +# - postconditions: slot Y has required: true +# +# Semantics: "If X <= N, then Y must be present." The threshold becomes a +# SPARQL FILTER; combined here with the M1 required violation. +# =========================================================================== + + +# =========================================================================== +# Compositional fallback: nested range_expression precondition (M4) +# =========================================================================== +# +# Rule shape: +# - preconditions: slot X (inlined child) has range_expression on an inner +# slot (e.g. sun_position.elevation <= 0) +# - postconditions: slot Y has required: true +# +# Semantics: "If the child's inner value satisfies the condition, then Y must +# be present." The SPARQL binds the child node with one extra hop. +# =========================================================================== + + +# =========================================================================== +# Compositional fallback: has_member list-membership postcondition (M5) +# =========================================================================== +# +# Rule shape: +# - preconditions: any supported precondition (here value_presence PRESENT) +# - postconditions: multivalued slot has_member with a nested +# range_expression constraining the member's inner slots +# +# Semantics: "If the precondition holds, the list must contain a member +# matching the inner conditions." Violation = no such member (FILTER NOT +# EXISTS over the members). Inner enum values resolve against the member +# class (LightControlGroup), which disambiguates the reused `type` slot. +# =========================================================================== + + +# =========================================================================== +# Rule-converter robustness regressions (review hardening) +# +# These guard three defects found while reviewing the rule converters: +# 1. A single precondition combining minimum_value + maximum_value dropped +# all but the first bound (silent under-constraint / false positives). +# 2. A slot_usage `slot_uri` (or enum `range`) override made the SPARQL body +# query the *base* IRI while `sh:path` used the *induced* IRI, so the +# constraint silently never fired (false negative). +# 3. An `equals_string` value containing a quote/backslash produced invalid, +# unparsable SPARQL (broken artifact / injection). +# =========================================================================== + + +_ENUM_NARROWING_SCHEMA_YAML = """ +id: https://example.org/enum-narrowing +name: enum_narrowing_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/enum-narrowing/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +enums: + BaseMode: + permissible_values: + Active: + meaning: ex:GLOBAL_Active + SceneMode: + permissible_values: + Active: + meaning: ex:LOCAL_Active + +slots: + activator: + range: string + slot_uri: ex:activator + mode: + range: BaseMode + slot_uri: ex:mode + +classes: + Scene: + class_uri: ex:Scene + slots: + - activator + - mode + slot_usage: + mode: + range: SceneMode + rules: + - description: If an activator is present the mode must be Active. + preconditions: + slot_conditions: + activator: + value_presence: PRESENT + postconditions: + slot_conditions: + mode: + equals_string: Active +""" + +EX_EN = rdflib.Namespace("https://example.org/enum-narrowing/") + + +def test_rule_enum_range_narrowed_by_slot_usage(): + """A slot_usage range override to a class-specific enum must resolve the + value's meaning against the induced (narrowed) enum, not the base range.""" + g = _parse_shacl(_ENUM_NARROWING_SCHEMA_YAML) + + nodes = list(g.objects(EX_EN.Scene, SH.sparql)) + assert len(nodes) == 1 + query = str(list(g.objects(nodes[0], SH.select))[0]) + assert str(EX_EN.LOCAL_Active) in query, f"must resolve the narrowed enum meaning, got:\n{query}" + assert "GLOBAL_Active" not in query, f"must not resolve the base enum meaning, got:\n{query}" + + +_ESCAPING_SCHEMA_YAML = """ +id: https://example.org/escaping +name: escaping_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/escaping/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +slots: + trigger: + range: string + slot_uri: ex:trigger + label: + range: string + slot_uri: ex:label + +classes: + Item: + class_uri: ex:Item + slots: + - trigger + - label + rules: + - description: If a trigger is present the label must equal the quoted marker. + preconditions: + slot_conditions: + trigger: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: 'a"b\\\\c' +""" + +EX_ESC = rdflib.Namespace("https://example.org/escaping/") + + +def test_rule_equals_string_special_chars_escaped(): + """An equals_string value with a quote and backslash must be escaped so the + generated SPARQL stays syntactically valid (no injection / broken query).""" + from rdflib.plugins.sparql import prepareQuery + + g = _parse_shacl(_ESCAPING_SCHEMA_YAML) + nodes = list(g.objects(EX_ESC.Item, SH.sparql)) + assert len(nodes) == 1 + query = str(list(g.objects(nodes[0], SH.select))[0]) + + # Would raise ParseException on the unescaped `... = "a"b\c"` form. + prepareQuery(query) + assert '\\"' in query, f"double quote must be escaped, got:\n{query}" + assert "\\\\" in query, f"backslash must be escaped, got:\n{query}" + + +# =========================================================================== +# Audit-fix regression tests: operator exactness, nested-slot resolution, +# numeric bound gating, elseconditions warning +# =========================================================================== + +_PIV_EXTRA_PRE_SCHEMA_YAML = """ +id: https://example.org/piv-extra-pre +name: piv_extra_pre +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/piv-extra-pre/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + temp: + range: integer + slot_uri: ex:temp + mode: + range: string + slot_uri: ex:mode +classes: + Device: + class_uri: ex:Device + slots: [temp, mode] + rules: + - description: Above 100 the mode must be High (extra precondition operator). + preconditions: + slot_conditions: + temp: + value_presence: PRESENT + minimum_value: 100 + postconditions: + slot_conditions: + mode: + equals_string: "High" +""" + + +def test_rule_extra_precondition_operator_skipped(): + """A precondition combining PRESENT with a threshold must not dispatch to + presence-implies-value: dropping the threshold widens the trigger.""" + g = _parse_shacl(_PIV_EXTRA_PRE_SCHEMA_YAML) + shape = URIRef("https://example.org/piv-extra-pre/Device") + assert list(g.objects(shape, SH.sparql)) == [], "rule with an untranslated conjunct must be skipped" + + +def test_rule_extra_precondition_operator_pyshacl_end_to_end(): + """A device below the threshold satisfies the rule vacuously and must conform.""" + import pyshacl + + shacl_ttl = ShaclGenerator(_PIV_EXTRA_PRE_SCHEMA_YAML, mergeimports=False, emit_rules=True).serialize() + data = """ + @prefix ex: . + + ex:cool a ex:Device ; ex:temp 50 ; ex:mode "Low" . + """ + conforms, _, txt = pyshacl.validate( + data_graph=data, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + ) + assert conforms, f"Below-threshold device must not be flagged:\n{txt}" + + +_POST_BOTH_EQUALS_SCHEMA_YAML = """ +id: https://example.org/post-both-equals +name: post_both_equals +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/post-both-equals/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + guard: + slot_uri: ex:guard + target: + slot_uri: ex:target +classes: + Thing: + class_uri: ex:Thing + slots: [guard, target] + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: "a" + equals_string_in: ["b", "c"] +""" + + +def test_rule_post_with_both_equals_forms_skipped(): + """equals_string and equals_string_in set together is ambiguous — skip, + do not let one form silently win.""" + g = _parse_shacl(_POST_BOTH_EQUALS_SCHEMA_YAML) + shape = URIRef("https://example.org/post-both-equals/Thing") + assert list(g.objects(shape, SH.sparql)) == [] + + +_MIXED_SCALAR_SCHEMA_YAML = """ +id: https://example.org/mixed-scalar +name: mixed_scalar +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/mixed-scalar/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + code: + slot_uri: ex:code + note: + slot_uri: ex:note +classes: + Obs: + class_uri: ex:Obs + slots: [code, note] + rules: + - preconditions: + slot_conditions: + code: + equals_string: fog + pattern: "^f" + postconditions: + slot_conditions: + note: + required: true +""" + + +def test_rule_recognized_plus_unrecognized_operator_skipped(): + """A condition mixing a supported operator (equals_string) with an + unsupported one (pattern) must skip — translating only the supported part + widens the trigger.""" + g = _parse_shacl(_MIXED_SCALAR_SCHEMA_YAML) + shape = URIRef("https://example.org/mixed-scalar/Obs") + assert list(g.objects(shape, SH.sparql)) == [] + + +_EXPR_ANY_OF_SCHEMA_YAML = """ +id: https://example.org/expr-any-of +name: expr_any_of +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/expr-any-of/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + code: + slot_uri: ex:code + other: + slot_uri: ex:other + note: + slot_uri: ex:note +classes: + Obs: + class_uri: ex:Obs + slots: [code, other, note] + rules: + - preconditions: + slot_conditions: + code: + equals_string: fog + any_of: + - slot_conditions: + other: + equals_string: x + - slot_conditions: + other: + equals_string: y + postconditions: + slot_conditions: + note: + required: true +""" + + +def test_rule_expression_level_any_of_skipped(): + """Expression-level any_of on the preconditions cannot be honoured by any + converter; dropping the branch widens the trigger, so the rule is skipped.""" + g = _parse_shacl(_EXPR_ANY_OF_SCHEMA_YAML) + shape = URIRef("https://example.org/expr-any-of/Obs") + assert list(g.objects(shape, SH.sparql)) == [] + + +def test_rule_expression_level_any_of_pyshacl_end_to_end(): + """An instance whose any_of branch is unmet satisfies the rule vacuously + and must conform.""" + import pyshacl + + shacl_ttl = ShaclGenerator(_EXPR_ANY_OF_SCHEMA_YAML, mergeimports=False, emit_rules=True).serialize() + data = """ + @prefix ex: . + + ex:o a ex:Obs ; ex:code "fog" ; ex:other "z" . + """ + conforms, _, txt = pyshacl.validate( + data_graph=data, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + ) + assert conforms, f"Instance with unmet any_of branch must not be flagged:\n{txt}" + + +_POST_MIXED_SCHEMA_YAML = """ +id: https://example.org/post-mixed +name: post_mixed +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/post-mixed/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + guard: + slot_uri: ex:guard + target: + slot_uri: ex:target +classes: + Thing: + class_uri: ex:Thing + slots: [guard, target] + rules: + - preconditions: + slot_conditions: + guard: + equals_string: on + postconditions: + slot_conditions: + target: + required: true + pattern: "^x" +""" + + +def test_rule_post_mixed_operators_skipped(): + """A postcondition combining required with an untranslated operator must + skip — checking only required weakens the postcondition.""" + g = _parse_shacl(_POST_MIXED_SCHEMA_YAML) + shape = URIRef("https://example.org/post-mixed/Thing") + assert list(g.objects(shape, SH.sparql)) == [] + + +_ABSENT_COMBINED_SCHEMA_YAML = """ +id: https://example.org/absent-combined +name: absent_combined +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/absent-combined/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + count: + range: integer + slot_uri: ex:count + note: + slot_uri: ex:note +classes: + Obs: + class_uri: ex:Obs + slots: [count, note] + rules: + - preconditions: + slot_conditions: + count: + value_presence: ABSENT + minimum_value: 5 + postconditions: + slot_conditions: + note: + required: true +""" + + +def test_rule_absent_combined_with_bound_skipped(): + """value_presence ABSENT combined with another operator must skip: the + triple-binding translation would invert the declared trigger.""" + g = _parse_shacl(_ABSENT_COMBINED_SCHEMA_YAML) + shape = URIRef("https://example.org/absent-combined/Obs") + assert list(g.objects(shape, SH.sparql)) == [] + + +_STRING_TRUE_SCHEMA_YAML = """ +id: https://example.org/string-true +name: string_true +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/string-true/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + opt: + slot_uri: ex:opt + status: + range: string + slot_uri: ex:status +classes: + Conf: + class_uri: ex:Conf + slots: [opt, status] + rules: + - description: If opt is present, status must be the string "true". + preconditions: + slot_conditions: + opt: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string: "true" +""" + + +def test_rule_equals_true_on_string_slot_uses_piv(): + """equals_string "true" on a NON-boolean slot must dispatch to + presence-implies-value (string comparison), not the boolean guard.""" + g = _parse_shacl(_STRING_TRUE_SCHEMA_YAML) + shape = URIRef("https://example.org/string-true/Conf") + sparql_nodes = list(g.objects(shape, SH.sparql)) + assert len(sparql_nodes) == 1 + query = str(list(g.objects(sparql_nodes[0], SH.select))[0]) + assert "NOT IN" in query, f"string-range 'true' must be a string comparison, got:\n{query}" + assert '"true"' in query, "the comparison term must be the string literal" + + +def test_rule_equals_true_on_string_slot_pyshacl_end_to_end(): + """status "true" (string) satisfies the rule; the boolean-guard hijack used + to flag it.""" + import pyshacl + + shacl_ttl = ShaclGenerator(_STRING_TRUE_SCHEMA_YAML, mergeimports=False, emit_rules=True).serialize() + conforming = """ + @prefix ex: . + + ex:ok a ex:Conf ; ex:opt "x" ; ex:status "true" . + """ + conforms, _, txt = pyshacl.validate( + data_graph=conforming, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + ) + assert conforms, f"status 'true' satisfies the rule and must conform:\n{txt}" + + violating = """ + @prefix ex: . + + ex:bad a ex:Conf ; ex:opt "x" ; ex:status "other" . + """ + conforms, _, txt = pyshacl.validate( + data_graph=violating, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + ) + assert not conforms, f"status 'other' violates the rule:\n{txt}" + + +_UNKNOWN_KEY_SCHEMA_YAML = """ +id: https://example.org/unknown-key +name: unknown_key +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/unknown-key/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + code: + slot_uri: ex:code + note: + slot_uri: ex:note +classes: + Obs: + class_uri: ex:Obs + slots: [code, note] + rules: + - description: A rule keyed on a nonexistent slot must be skipped. + preconditions: + slot_conditions: + no_such_slot: + equals_string: trigger + postconditions: + slot_conditions: + note: + required: true +""" + + +def test_rule_unknown_slot_key_skipped(): + """A rule whose condition keys a slot that does not exist must be skipped: + fabricating a default-prefix predicate would emit a constraint that can + never fire (or, for has_member, always fires).""" + g = _parse_shacl(_UNKNOWN_KEY_SCHEMA_YAML) + shape = URIRef("https://example.org/unknown-key/Obs") + assert list(g.objects(shape, SH.sparql)) == []