From 9833324649857dadea2f18f50c31a732a0cf58c4 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 30 Oct 2026 22:13:49 +0100 Subject: [PATCH 1/5] fix(rdf): give values of URI-like types one RDF term kind in every generator (#4156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A value of a type is an IRI, a blank node or a literal according to one rule, linkml_runtime.utils.rdf_terms.type_term, which gen-jsonld-context, gen-owl, gen-shacl, gen-shex, gen-yarrrml and the RDF dumper and loader now share: - uri, uriorcurie and the types derived from them are IRIs; - objectidentifier is an IRI, and nodeidentifier and types with uri: rdfs:Resource are IRIs or blank nodes; - every other type is a literal of its datatype, including a type derived from string with uri: xsd:anyURI, which keeps URI references as text. The rule resolves the inherited, expanded datatype, so its spelling does not matter, and recognises uri and uriorcurie by name and definition rather than by from_schema, which merging imports rewrites. A datatype with an undeclared prefix is an error (xsd, shex and rdfs are known). The dumper writes the inherited datatype of a derived type and rejects relative values of IRI types (RDF 1.1 Concepts §3.2); the loader accepts IRIs for node-kind ranges in strict mode, keeps uri values complete, compacts uriorcurie values to CURIEs and keeps blank-node labels. gen-shacl gives sh:in values and sh:defaultValue the slot's term kind. The --xsd-anyuri-as-iri option of gen-owl and gen-jsonld-context is deprecated in 1.13.0, has no effect, and is scheduled for removal in 1.14.0. Signed-off-by: jdsika --- docs/generators/jsonld-context.rst | 6 + docs/generators/owl.rst | 3 + docs/schemas/advanced.md | 56 +++ .../linkml/generators/common/subproperty.py | 33 -- .../src/linkml/generators/jsonldcontextgen.py | 75 ++- .../linkml/src/linkml/generators/owlgen.py | 60 +-- .../src/linkml/generators/prefixmapgen.py | 5 +- .../linkml/src/linkml/generators/shaclgen.py | 56 +-- .../linkml/src/linkml/generators/shexgen.py | 17 +- .../src/linkml/generators/yarrrmlgen.py | 13 +- .../linkml/src/linkml/utils/deprecation.py | 16 + packages/linkml/src/linkml/utils/generator.py | 26 +- .../linkml/src/linkml/utils/schemaloader.py | 5 +- .../linkml_runtime/dumpers/rdflib_dumper.py | 19 +- .../linkml_runtime/loaders/rdflib_loader.py | 17 +- .../src/linkml_runtime/utils/rdf_terms.py | 207 ++++++++ .../__snapshots__/annotations.context.jsonld | 2 +- .../test_base/__snapshots__/annotations.json | 2 +- .../__snapshots__/extensions.context.jsonld | 2 +- .../test_base/__snapshots__/extensions.json | 2 +- .../__snapshots__/mappings.context.jsonld | 16 +- .../test_base/__snapshots__/mappings.json | 16 +- .../__snapshots__/meta.context.jsonld | 78 ++-- .../linkml/test_base/__snapshots__/meta.json | 78 ++-- .../__snapshots__/biolink.context.jsonld | 26 +- .../__snapshots__/biolink.json | 26 +- .../test_generators/test_jsonldcontextgen.py | 442 ++++-------------- tests/linkml/test_generators/test_shaclgen.py | 6 +- .../test_generators/test_uri_term_kinds.py | 323 +++++++++++++ .../simple_uri_test.merge.context.jsonld | 2 +- ...e_uri_test.merge.model_only.context.jsonld | 2 +- .../simple_uri_test.no_merge.context.jsonld | 2 +- ...ri_test.no_merge.model_only.context.jsonld | 2 +- .../genjsonld/simple_uri_test.context.jsonld | 2 +- .../test_utils/__snapshots__/uriandcurie.json | 2 +- .../__snapshots__/uriandcurie.jsonld | 2 +- .../test_utils/test_rdf_terms.py | 202 ++++++++ 37 files changed, 1190 insertions(+), 659 deletions(-) create mode 100644 packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py create mode 100644 tests/linkml/test_generators/test_uri_term_kinds.py create mode 100644 tests/linkml_runtime/test_utils/test_rdf_terms.py diff --git a/docs/generators/jsonld-context.rst b/docs/generators/jsonld-context.rst index da5e4df794..18cec34644 100644 --- a/docs/generators/jsonld-context.rst +++ b/docs/generators/jsonld-context.rst @@ -47,6 +47,12 @@ declarations and Any JSON that conforms to the derived JSON Schema (see above) can be converted to RDF using this context. +Values of ``uri``, ``uriorcurie``, the types derived from them, and the node +types ``objectidentifier`` and ``nodeidentifier`` are coerced to ``@id``; a type +derived from ``string`` with ``uri: xsd:anyURI`` keeps URI references as +``xsd:anyURI`` literals (see +:ref:`links-and-uri-references`). + CURIE usage ^^^^^^^^^^^ diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 4b6f076fe8..9ce2ee0994 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -33,6 +33,9 @@ Mapping * Each LinkML slot maps to an OWL property - if the range of the slot is class, then an ObjectProperty is used + - an ObjectProperty is also used when the range is a type whose values are IRIs or blank + nodes: ``uri``, ``uriorcurie`` and the types derived from them, ``objectidentifier`` and + ``nodeidentifier`` (see :ref:`links-and-uri-references`) - otherwise DataProperty is used - Exception to the above: if ``type_objects`` is set then ObjectProperties are always used; instead of having ranges of type be mapped to literals, these map to class-shadows of diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md index c56a808379..5e19b5d14f 100644 --- a/docs/schemas/advanced.md +++ b/docs/schemas/advanced.md @@ -211,3 +211,59 @@ details of how to use this in code. In some cases, you may want to use a URI or CURIE as a range. To do this, create a class with `class_uri` set to the URI or CURIE and use that class as the range. + +(links-and-uri-references)= +## Links and URI references + +A value of a URI-like type is either a link, which becomes an RDF IRI, or a URI +reference held as text, which becomes an `xsd:anyURI` literal. The type decides, +in the same way for every generator and for the RDF dumper and loader: + +| Type | RDF term | Use for | +|---|---|---| +| `uri`, `uriorcurie` and types derived from them | IRI (CURIEs are expanded) | links to resources | +| `objectidentifier` | IRI | references to objects | +| `nodeidentifier`, or a type with `uri: rdfs:Resource` | IRI or blank node (`_:b1`) | references to nodes | +| a type derived from `string` with `uri: xsd:anyURI` | `xsd:anyURI` literal, as written | URI references held as data, such as relative file paths | +| `curie` | `xsd:string` literal, not expanded | compact identifiers held as text | + +```yaml +types: + FilePath: + typeof: string + uri: xsd:anyURI + description: A file location, which may be relative. +slots: + homepage: + range: uri + file_path: + range: FilePath +``` + +For `homepage: https://alice.example.org/` and `file_path: ./docs/cv.pdf` the RDF is + +```turtle +ex:alice ex:homepage ; + ex:file_path "./docs/cv.pdf"^^xsd:anyURI . +``` + +gen-jsonld-context coerces `homepage` to `@id` and `file_path` to `xsd:anyURI`, +gen-shacl requires `sh:nodeKind sh:IRI` for one and `sh:datatype xsd:anyURI` for +the other, and gen-owl makes `homepage` an object property and `file_path` a data +property. + +An RDF IRI must be absolute ([RDF 1.1 Concepts §3.2](https://www.w3.org/TR/rdf11-concepts/#section-IRIs)), +so the RDF dumper rejects a relative value of a link type, such as `./home.html` in +`homepage`, and JSON Schema validation rejects it for `uri` and the types derived +from it (`format: uri`). Through a JSON-LD context, `@id` values are resolved +against the base IRI of the document. Give values that may be relative a text type +such as `FilePath`. + +The RDF loader reads an IRI back as a complete URI for `uri` and the types derived +from it, and as a CURIE, where the schema declares the prefix, for `uriorcurie`, +`objectidentifier` and `nodeidentifier`. + +The `--xsd-anyuri-as-iri` option of gen-owl and gen-jsonld-context is deprecated +and has no effect: links are IRIs without it. A schema that relied on the earlier +default, where `uri` values were `xsd:anyURI` literals in the JSON-LD context and +in OWL, keeps that output by giving those slots a text type such as `FilePath`. diff --git a/packages/linkml/src/linkml/generators/common/subproperty.py b/packages/linkml/src/linkml/generators/common/subproperty.py index 9b136e2429..4687c38218 100644 --- a/packages/linkml/src/linkml/generators/common/subproperty.py +++ b/packages/linkml/src/linkml/generators/common/subproperty.py @@ -15,10 +15,6 @@ CURIE_TYPES: frozenset[str] = frozenset({"uriorcurie", "curie"}) URI_TYPES: frozenset[str] = frozenset({"uri"}) -# Types whose XSD mapping is xsd:anyURI (not xsd:string). -# ``curie`` maps to xsd:string and is deliberately excluded. -_ANYURI_TYPES: frozenset[str] = frozenset({"uri", "uriorcurie"}) - def is_uri_range(sv: SchemaView, range_type: str | None) -> bool: """ @@ -67,35 +63,6 @@ def is_curie_range(sv: SchemaView, range_type: str | None) -> bool: return False -def is_xsd_anyuri_range(sv: SchemaView, range_type: str | None) -> bool: - """Check if range type resolves to ``xsd:anyURI``. - - Returns True for ``uri``, ``uriorcurie``, and types that inherit from them. - Returns False for ``curie`` (which maps to ``xsd:string``). - - This is the correct predicate for the ``--xsd-anyuri-as-iri`` flag: only - types whose XSD representation is ``xsd:anyURI`` should be promoted from - literal to IRI semantics. ``curie`` is a compact string representation - that resolves to ``xsd:string`` and must not be affected. - - :param sv: SchemaView for type ancestry lookup - :param range_type: The range type to check - :return: True if range type maps to xsd:anyURI - """ - if range_type is None: - return False - - if range_type in _ANYURI_TYPES: - return True - - if range_type in sv.all_types(): - type_ancestors = set(sv.type_ancestors(range_type)) - if type_ancestors & _ANYURI_TYPES: - return True - - return False - - def format_slot_value_for_range(sv: SchemaView, slot_name: str, range_type: str | None) -> str: """ Format slot value according to the declared range type. diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py index 5462b29f8b..4916fa07c3 100644 --- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py +++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py @@ -14,19 +14,13 @@ from rdflib import SKOS, XSD, Namespace from linkml._version import __version__ -from linkml.utils.deprecation import deprecated_fields +from linkml.utils.deprecation import XSD_ANYURI_AS_IRI_OPTION, deprecated_fields, deprecation_warning from linkml.utils.generator import Generator, shared_arguments from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition -from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import camelcase, underscore +from linkml_runtime.utils.rdf_terms import type_term from linkml_runtime.utils.schemaview import SchemaView -URI_RANGES = (SHEX.nonliteral, SHEX.bnode, SHEX.iri) - -# Extended URI_RANGES that also treats xsd:anyURI as an IRI reference (@id) -# rather than a typed literal. Opt-in via --xsd-anyuri-as-iri flag. -URI_RANGES_WITH_XSD = (*URI_RANGES, XSD.anyURI) - ENUM_CONTEXT = { "text": "skos:notation", "description": "skos:prefLabel", @@ -81,12 +75,9 @@ class ContextGenerator(Generator): context keys. Identifier slots retain their slot-name key and map to ``@id``. """ - xsd_anyuri_as_iri: bool = False - """Map xsd:anyURI-typed ranges (uri, uriorcurie) to ``@type: @id`` instead of ``@type: xsd:anyURI``. - - This aligns the JSON-LD context with the SHACL generator, which emits - ``sh:nodeKind sh:IRI`` for the same types. - """ + xsd_anyuri_as_iri: bool | None = None + """Deprecated and without effect: values of ``uri``, ``uriorcurie`` and the types + derived from them are always coerced to ``@id``.""" # Framing (opt-in via CLI flag) emit_frame: bool = False @@ -95,6 +86,8 @@ class ContextGenerator(Generator): frame_root: str | None = None def __post_init__(self) -> None: + if self.xsd_anyuri_as_iri is not None: + deprecation_warning(XSD_ANYURI_AS_IRI_OPTION, stack_level=4) super().__post_init__() if self.namespaces is None: raise TypeError("Schema text must be supplied to context generator. Preparsed schema will not work") @@ -299,19 +292,9 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N and "could not resolve safely because the branches disagree". """ coercions: set[str | None] = set() - uri_ranges = URI_RANGES_WITH_XSD if self.xsd_anyuri_as_iri else URI_RANGES for range_name in ranges: - if range_name not in self.schema.types: - continue - - range_type = self.schema.types[range_name] - range_uri = self.namespaces.uri_for(range_type.uri) - if range_uri == XSD.string: - coercions.add(None) - elif range_uri in uri_ranges: - coercions.add("@id") - else: - coercions.add(range_type.uri) + if range_name in self.schema.types: + coercions.add(self._type_coercion(range_name)) if not coercions: return False, None @@ -413,14 +396,9 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: skos = "skos" self.emit_prefixes.add(skos) else: - range_type = self.schema.types[slot.range] - uri_ranges = URI_RANGES_WITH_XSD if self.xsd_anyuri_as_iri else URI_RANGES - if self.namespaces.uri_for(range_type.uri) == XSD.string: - pass - elif self.namespaces.uri_for(range_type.uri) in uri_ranges: - slot_def["@type"] = "@id" - else: - slot_def["@type"] = range_type.uri + coercion = self._type_coercion(slot.range) + if coercion is not None: + slot_def["@type"] = coercion if self.fix_multivalue_containers and slot.multivalued: if slot.inlined and not slot.inlined_as_list: @@ -457,15 +435,25 @@ def _resolve_slot_type(self, range_name: str) -> str | None: if range_name in self.schema.enums: return None if range_name in self.schema.types: - range_type = self.schema.types[range_name] - uri = self.namespaces.uri_for(range_type.uri) - if uri == XSD.string: - return None - if uri in URI_RANGES: - return "@id" - return range_type.uri + return self._type_coercion(range_name) return None + def _type_coercion(self, type_name: str) -> str | None: + """Return the JSON-LD ``@type`` coercion of values of the type *type_name*. + + Values of node types (:func:`~linkml_runtime.utils.rdf_terms.type_term`) are + coerced to ``@id``, which also turns ``_:`` labels into blank nodes. Literals + are coerced to their datatype as the schema writes it; plain strings need no + coercion, so the result is ``None``. + """ + view = self.type_schemaview() + term = type_term(view, type_name) + if term.kind.is_node: + return "@id" + if term.datatype is None or term.datatype == str(XSD.string): + return None + return view.induced_type(type_name).uri + def _build_scoped_context(self, cls: ClassDefinition) -> dict: """Build a scoped JSON-LD context for class-level slot range overrides. @@ -605,9 +593,8 @@ def serialize( ) @click.option( "--xsd-anyuri-as-iri/--no-xsd-anyuri-as-iri", - default=False, - show_default=True, - help="Map xsd:anyURI-typed ranges (uri, uriorcurie) to @type: @id instead of @type: xsd:anyURI.", + default=None, + help="Deprecated and without effect: values of uri, uriorcurie and the types derived from them are always IRIs.", ) @click.option( "--use-curies/--not-use-curies", diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 3f16c4965c..5580a7dcd0 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -19,8 +19,7 @@ from linkml import METAMODEL_NAMESPACE_NAME from linkml._version import __version__ -from linkml.generators.common.subproperty import is_xsd_anyuri_range -from linkml.utils.deprecation import deprecation_warning +from linkml.utils.deprecation import XSD_ANYURI_AS_IRI_OPTION, deprecation_warning from linkml.utils.generator import Generator, shared_arguments from linkml.utils.language_tags import LanguageTagResolver from linkml_runtime import SchemaView @@ -44,6 +43,7 @@ from linkml_runtime.utils.formatutils import camelcase, underscore from linkml_runtime.utils.introspection import package_schemaview from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph +from linkml_runtime.utils.rdf_terms import type_term logger = logging.getLogger(__name__) @@ -226,23 +226,9 @@ def _present(values: Iterable[_T | None]) -> list[_T]: return [value for value in values if value is not None] - xsd_anyuri_as_iri: bool = False - """Treat ``range: uri`` / ``range: uriorcurie`` slots as ``owl:ObjectProperty`` - instead of ``owl:DatatypeProperty`` with ``rdfs:range xsd:anyURI``. - - This aligns the OWL output with the SHACL generator (which emits - ``sh:nodeKind sh:IRI``) and the JSON-LD context generator (which emits - ``@type: @id`` when its own ``--xsd-anyuri-as-iri`` flag is set). - - Without this flag, ``range: uri`` produces a semantic inconsistency: - OWL says the value is a literal (``DatatypeProperty``), while SHACL and - JSON-LD say it is an IRI node. Enabling the flag makes all three - generators consistent. - - When enabled, URI-range slots: - - become ``owl:ObjectProperty`` (not ``owl:DatatypeProperty``) - - have no ``rdfs:range`` restriction (any IRI is valid) - """ + xsd_anyuri_as_iri: bool | None = None + """Deprecated and without effect: slots whose values are IRIs or blank nodes + (:func:`~linkml_runtime.utils.rdf_terms.type_term`) are always object properties.""" default_language: str | None = None """Default BCP 47 language tag for human-readable string literals. @@ -268,6 +254,8 @@ def __post_init__(self) -> None: # once here; per-element tags are validated lazily, with at most one # warning per distinct malformed tag. self._language_resolver = LanguageTagResolver(self.default_language) + if self.xsd_anyuri_as_iri is not None: + deprecation_warning(XSD_ANYURI_AS_IRI_OPTION, stack_level=4) super().__post_init__() def _resolve_language(self, element: "Definition | PermissibleValue | None" = None) -> str | None: @@ -801,7 +789,8 @@ def slot_node_owltypes( """ Determine the OWL types of a named slot or slot expression - The OWL type is either OWL.Thing or RDFS.Datatype + The OWL type is either OWL.Thing or RDFS.Datatype. A type range gives OWL.Thing + when its values are IRIs or blank nodes (:meth:`_type_values_are_nodes`). :param slot: :param owning_class: @@ -818,7 +807,7 @@ def slot_node_owltypes( if not (range_class and range_class.class_uri == "linkml:Any"): node_types.add(OWL.Thing) if slot.range in sv.all_types(): - node_types.add(RDFS.Datatype) + node_types.add(OWL.Thing if self._type_values_are_nodes(slot.range) else RDFS.Datatype) for k in ["any_of", "all_of", "exactly_one_of", "none_of"]: subslots = getattr(slot, k, None) if not subslots: @@ -905,7 +894,7 @@ def _get_slot_nodes( this_owl_types: set[OWL_TYPE] = set() if slot_range: if slot_range in sv.all_types(imports=True): - if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(sv, slot_range): + if self._type_values_are_nodes(slot_range): self.slot_is_literal_map[main_slot.name].add(False) this_owl_types.add(OWL.Thing) else: @@ -1546,12 +1535,14 @@ def _boolean_expression( graph.add((node, RDF.type, RDFS.Datatype)) return node - def _range_is_datatype(self, slot: SlotDefinition) -> bool: - if self.type_objects: - return False - if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(self.schemaview, slot.range): - return False - return slot.range in self.schema.types + def _type_values_are_nodes(self, type_name: str) -> bool: + """Whether values of the type *type_name* are IRIs or blank nodes rather than literals. + + This follows :func:`~linkml_runtime.utils.rdf_terms.type_term`. With + :attr:`type_objects`, every typed value is an individual of the type's class + instead, so no type is treated differently. + """ + return not self.type_objects and type_term(self.schemaview, type_name).kind.is_node def _range_uri(self, slot: SlotDefinition) -> URIRef: if slot.range in self.schema.types: @@ -1699,7 +1690,7 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef: elif slot_range in sv.all_enums(): return OWL.ObjectProperty elif slot_range in sv.all_types(): - if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(sv, slot_range): + if self._type_values_are_nodes(slot_range): return OWL.ObjectProperty return OWL.DatatypeProperty else: @@ -1827,14 +1818,9 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef: ) @click.option( "--xsd-anyuri-as-iri/--no-xsd-anyuri-as-iri", - default=False, - show_default=True, - help=( - "Treat range: uri / range: uriorcurie slots as owl:ObjectProperty (IRI node) " - "instead of owl:DatatypeProperty with rdfs:range xsd:anyURI (literal). " - "Aligns OWL output with the SHACL generator (sh:nodeKind sh:IRI) and " - "the JSON-LD context generator (--xsd-anyuri-as-iri → @type: @id)." - ), + default=None, + help="Deprecated and without effect: slots whose values are IRIs, such as uri and uriorcurie, are always " + "object properties.", ) @click.option( "--default-language", diff --git a/packages/linkml/src/linkml/generators/prefixmapgen.py b/packages/linkml/src/linkml/generators/prefixmapgen.py index eaf6d590e7..c635520d46 100644 --- a/packages/linkml/src/linkml/generators/prefixmapgen.py +++ b/packages/linkml/src/linkml/generators/prefixmapgen.py @@ -8,16 +8,13 @@ import click from jsonasobj2 import JsonObj, as_json -from rdflib import XSD, Namespace +from rdflib import Namespace from linkml._version import __version__ from linkml.utils.generator import Generator, shared_arguments from linkml_runtime.linkml_model.meta import ClassDefinition, SlotDefinition -from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import camelcase -URI_RANGES = (XSD.anyURI, SHEX.nonliteral, SHEX.bnode, SHEX.iri) - @dataclass class PrefixGenerator(Generator): diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index eb6b48e9d6..edaf6f7e38 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -3,6 +3,7 @@ import string from collections.abc import Callable from dataclasses import dataclass +from typing import ClassVar import click from jsonasobj2 import JsonObj, as_dict @@ -11,7 +12,7 @@ from rdflib.namespace import RDF, RDFS, SH, XSD from linkml._version import __version__ -from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range +from linkml.generators.common.subproperty import get_subproperty_values from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor from linkml.utils.generator import Generator, shared_arguments @@ -19,6 +20,7 @@ from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph +from linkml_runtime.utils.rdf_terms import TermKind, type_term, value_term from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str logger = logging.getLogger(__name__) @@ -437,6 +439,9 @@ def st_node_pv(p, v): default_value = ifabsent_processor.process_slot(s, c) if default_value: + if s.range in sv.all_types() and type_term(sv, s.range).kind.is_node: + # The default is a value of the slot, so it has the slot's term kind. + default_value = value_term(sv, s.range, str(default_value)) prop_pv(SH.defaultValue, default_value) if self.emit_rules: @@ -719,40 +724,23 @@ def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None: ) func(SH["in"], pv_node) - # Type URIs denoting non-literal (IRI or blank-node) values. - # SHACL §4.8.1 - # defines sh:IRI, sh:BlankNode, and sh:BlankNodeOrIRI as valid node kinds. - # These URIs map to sh:IRI or sh:BlankNodeOrIRI constraints (never sh:Literal). - _NON_LITERAL_TYPE_URIS = frozenset( - { - "xsd:anyURI", # uri, uriorcurie → sh:IRI - "http://www.w3.org/ns/shex#nonLiteral", # nodeidentifier → sh:BlankNodeOrIRI - "http://www.w3.org/ns/shex#iri", # future-proofing → sh:IRI - } - ) - # IRI-only subset: uri/uriorcurie must be strict IRI references (sh:IRI), - # while nodeidentifier (shex:nonLiteral) allows blank nodes too (sh:BlankNodeOrIRI). - # See RDF 1.1 §3.2–3.3 . - _IRI_ONLY_TYPE_URIS = frozenset( - { - "xsd:anyURI", - } - ) + # SHACL §4.1.3 node kinds for the node term kinds of LinkML types. + _SH_NODE_KINDS: ClassVar[dict[TermKind, URIRef]] = { + TermKind.IRI: SH.IRI, + TermKind.BLANK_NODE: SH.BlankNode, + TermKind.BLANK_NODE_OR_IRI: SH.BlankNodeOrIRI, + } def _add_type(self, func: Callable, r: ElementName) -> None: sv = self.schemaview # Types can inherit URI and pattern constraints. rt = sv.induced_type(r) - type_uri = rt.uri - expanded = sv.get_uri(rt, expand=True) if type_uri else None - if type_uri and (type_uri in self._NON_LITERAL_TYPE_URIS or expanded in self._NON_LITERAL_TYPE_URIS): - if type_uri in self._IRI_ONLY_TYPE_URIS: - func(SH.nodeKind, SH.IRI) - else: - func(SH.nodeKind, SH.BlankNodeOrIRI) - elif type_uri: + term = type_term(sv, r) if rt.uri else None + if term is not None and term.kind.is_node: + func(SH.nodeKind, self._SH_NODE_KINDS[term.kind]) + elif term is not None: func(SH.nodeKind, SH.Literal) - func(SH.datatype, URIRef(sv.get_uri(rt, expand=True))) + func(SH.datatype, URIRef(term.datatype)) if rt.pattern: func(SH.pattern, Literal(rt.pattern)) if rt.annotations and self.include_annotations: @@ -790,17 +778,17 @@ def _get_subproperty_values(self, slot) -> list: """ Get all valid values from slot hierarchy for subproperty_of constraint. - Values are formatted according to range type: - - uri/uriorcurie: Returns URIRef objects with full URIs - - string: Returns Literal objects with slot names + Values take the term kind of the slot's range type: + - IRI types (uri, uriorcurie): URIRef objects with full URIs + - literal types (string, curie): Literal objects with slot names or CURIEs :param slot: SlotDefinition with subproperty_of set :return: List of URIRef or Literal objects for sh:in constraint """ sv = self.schemaview - # SHACL uses full URIs for URI-like ranges - use_uris = is_uri_range(sv, slot.range) + # sh:in matches terms exactly (SHACL §4.8.3), so the values take the slot's term kind. + use_uris = slot.range in sv.all_types() and type_term(sv, slot.range).kind.is_node # Get string values from shared utility # For URI ranges, get full URIs; for string ranges, get formatted names diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py index e74e45225e..0fdddb92cb 100644 --- a/packages/linkml/src/linkml/generators/shexgen.py +++ b/packages/linkml/src/linkml/generators/shexgen.py @@ -6,7 +6,7 @@ import click from jsonasobj import as_json as as_json_1 -from rdflib import OWL, RDF, XSD, Graph, Namespace +from rdflib import OWL, RDF, Graph, Namespace, URIRef from ShExJSG import ShExC from ShExJSG.SchemaWithContext import Schema from ShExJSG.ShExJ import IRIREF, EachOf, NodeConstraint, Shape, ShapeOr, TripleConstraint @@ -23,10 +23,10 @@ SlotDefinitionName, TypeDefinition, ) -from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import camelcase, sfx from linkml_runtime.utils.metamodelcore import URIorCURIE from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph +from linkml_runtime.utils.rdf_terms import TermKind, type_term @dataclass @@ -38,6 +38,8 @@ class ShExGenerator(Generator): file_extension = "shex.rdf" visit_all_class_slots = False uses_schemaloader = True + # ShEx 2.1 §5.4.2 node kinds for the node term kinds of LinkML types + _SHEX_NODE_KINDS = {TermKind.IRI: "iri", TermKind.BLANK_NODE: "bnode", TermKind.BLANK_NODE_OR_IRI: "nonliteral"} # ObjectVars diff_stable: bool = False @@ -71,16 +73,15 @@ def visit_schema(self, **_): context = self.shex["@context"] self.shex["@context"] = [context, {"@base": self.namespaces._base}] # Emit all of the type definitions + type_view = self.type_schemaview() for typ in self.schema.types.values(): model_uri = self._class_or_type_uri(typ) if typ.uri: - typ_type_uri = self.namespaces.uri_for(typ.uri) - if typ_type_uri in (XSD.anyURI, SHEX.iri): - self.shapes.append(NodeConstraint(id=model_uri, nodeKind="iri")) - elif typ_type_uri == SHEX.nonLiteral: - self.shapes.append(NodeConstraint(id=model_uri, nodeKind="nonliteral")) + term = type_term(type_view, typ.name) + if term.kind.is_node: + self.shapes.append(NodeConstraint(id=model_uri, nodeKind=self._SHEX_NODE_KINDS[term.kind])) else: - self.shapes.append(NodeConstraint(id=model_uri, datatype=self.namespaces.uri_for(typ.uri))) + self.shapes.append(NodeConstraint(id=model_uri, datatype=URIRef(term.datatype))) else: typeof_uri = self._class_or_type_uri(typ.typeof) self.shapes.append(Shape(id=model_uri, expression=typeof_uri)) diff --git a/packages/linkml/src/linkml/generators/yarrrmlgen.py b/packages/linkml/src/linkml/generators/yarrrmlgen.py index 6847dafbd1..887c0eaae3 100644 --- a/packages/linkml/src/linkml/generators/yarrrmlgen.py +++ b/packages/linkml/src/linkml/generators/yarrrmlgen.py @@ -10,6 +10,7 @@ from linkml._version import __version__ from linkml.utils.generator import Generator, shared_arguments from linkml_runtime.linkml_model.meta import ClassDefinition +from linkml_runtime.utils.rdf_terms import TermKind, type_term class FlowList(list): @@ -343,10 +344,14 @@ def _po_list_for_class(self, c: ClassDefinition, inline_owners: dict) -> list[di # Scalar values and datatype mapping datatype = None - t = sv.get_type(s.range) if s.range else None - - if t: - datatype = sv.get_uri(t, expand=False) + if s.range in sv.all_types(): + term = type_term(sv, s.range) + if term.kind.is_node: + # YARRRML term types are iri, blank and literal; a kind that admits IRIs maps to iri. + term_type = "blank" if term.kind is TermKind.BLANK_NODE else "iri" + po.append({"p": pred, "o": {"value": f"$({var})", "type": term_type}}) + continue + datatype = sv.induced_type(s.range).uri if datatype: po.append({"p": pred, "o": {"value": f"$({var})", "datatype": str(datatype)}}) diff --git a/packages/linkml/src/linkml/utils/deprecation.py b/packages/linkml/src/linkml/utils/deprecation.py index 9cded2f46b..fc34c87093 100644 --- a/packages/linkml/src/linkml/utils/deprecation.py +++ b/packages/linkml/src/linkml/utils/deprecation.py @@ -322,6 +322,21 @@ def warn(self, stack_level=3, **kwargs): recommendation="Use `package` (`--package` on the command line) instead.", issue=3780, ), + Deprecation( + name="xsd-anyuri-as-iri-option", + deprecated_in=SemVer.from_str("1.13.0"), + removed_in=SemVer.from_str("1.14.0"), + message=( + "The `xsd_anyuri_as_iri` option (`--xsd-anyuri-as-iri` on the command line) of gen-owl and " + "gen-jsonld-context has no effect: values of `uri`, `uriorcurie` and the types derived from " + "them are IRIs in every generator." + ), + recommendation=( + "Remove the option. To keep a URI reference as text (an xsd:anyURI literal), use a type " + "derived from `string` with `uri: xsd:anyURI`." + ), + issue=2495, + ), ) # type: tuple[Deprecation, ...] EMITTED = set() # type: set[str] @@ -357,6 +372,7 @@ def deprecation_warning(name: str, stack_level: int = 3): METADATA_FLAG = "metadata-flag" MATERIALIZE_PATTERNS_GENERATOR_OPTION = "materialize-patterns-generator-option" +XSD_ANYURI_AS_IRI_OPTION = "xsd-anyuri-as-iri-option" T = TypeVar("T") diff --git a/packages/linkml/src/linkml/utils/generator.py b/packages/linkml/src/linkml/utils/generator.py index 8c916ccb03..d5d660f4ae 100644 --- a/packages/linkml/src/linkml/utils/generator.py +++ b/packages/linkml/src/linkml/utils/generator.py @@ -36,7 +36,7 @@ from linkml.cli.logging import DEFAULT_LOG_LEVEL_INT, log_level_option from linkml.utils.deprecation import METADATA_FLAG, deprecation_warning from linkml.utils.mergeutils import alias_root -from linkml.utils.schemaloader import SchemaLoader +from linkml.utils.schemaloader import UNKNOWN_NAMESPACE, SchemaLoader from linkml.utils.typereferences import References from linkml_runtime import SchemaView from linkml_runtime.linkml_model.meta import ( @@ -47,6 +47,7 @@ ElementName, EnumDefinition, EnumDefinitionName, + Prefix, PrefixPrefixPrefix, SchemaDefinition, SlotDefinition, @@ -175,6 +176,8 @@ class Generator(metaclass=abc.ABCMeta): it is preserved here so external callers/subclasses relying on it are not silently broken by the switch to a property-backed field. """ + _loaded_schemaview: ClassVar[SchemaView | None] = None + """SchemaView built by :meth:`type_schemaview` for a SchemaLoader-based generator.""" @property def namespaces(self) -> Namespaces | None: @@ -214,6 +217,25 @@ def namespaces(self, value: Namespaces | None) -> None: :attr:`namespaces` property.""" self._namespaces = value + def type_schemaview(self) -> SchemaView: + """Return a SchemaView of the schema for questions about its types, on either generator path. + + SchemaLoader-based generators have no :attr:`schemaview`. Their loaded schema + already has its imports resolved, so the view is built once from a copy of it + with the loaded prefixes, without reopening source files. Placeholder namespaces + that SchemaLoader gives undeclared prefixes are left out. + """ + if self.schemaview is not None: + return self.schemaview + if self._loaded_schemaview is None: + schema = deepcopy(self.schema) + schema.imports = [] + for prefix, namespace in self.namespaces.items(): + if not prefix.startswith("@") and not str(namespace).startswith(UNKNOWN_NAMESPACE): + schema.prefixes[prefix] = Prefix(prefix, str(namespace)) + self._loaded_schemaview = SchemaView(schema) + return self._loaded_schemaview + namespaces: Namespaces | None = namespaces """Constructor kwarg backing the ``namespaces`` property (see above). @@ -985,7 +1007,7 @@ def add_prefix(self, ncname: str) -> None: """ if ncname not in self.namespaces: self.logger.warning(f"Unrecognized prefix: {ncname}") - self.namespaces[ncname] = f"http://example.org/UNKNOWN/{ncname}/" + self.namespaces[ncname] = f"{UNKNOWN_NAMESPACE}{ncname}/" self.emit_prefixes.add(ncname) def get_metamodel_slot_name(self, slot_name: str) -> str: diff --git a/packages/linkml/src/linkml/utils/schemaloader.py b/packages/linkml/src/linkml/utils/schemaloader.py index 16e9b208b2..68284fb6fe 100644 --- a/packages/linkml/src/linkml/utils/schemaloader.py +++ b/packages/linkml/src/linkml/utils/schemaloader.py @@ -32,6 +32,9 @@ from linkml_runtime.utils.namespaces import Namespaces from linkml_runtime.utils.yamlutils import TypedNode +# Namespace given to a prefix that is used but not declared; not a declaration of the schema. +UNKNOWN_NAMESPACE = "http://example.org/UNKNOWN/" + lgr = logging.getLogger(__name__) @@ -1067,7 +1070,7 @@ def check_prefix(self, prefix_or_curie_or_uri: str) -> None: if prefix: if prefix not in self.namespaces: self.logger.warning(f"{TypedNode.yaml_loc(prefix_or_curie_or_uri)}Unrecognized prefix: {prefix}") - self.namespaces[prefix] = f"http://example.org/UNKNOWN/{prefix}/" + self.namespaces[prefix] = f"{UNKNOWN_NAMESPACE}{prefix}/" else: case_adjusted_prefix = self.namespaces.prefix_for(prefix_or_curie_or_uri, case_shift=True) if case_adjusted_prefix != prefix: diff --git a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py index d7a133f355..b426ff16f4 100644 --- a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py +++ b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py @@ -4,7 +4,7 @@ from curies import Converter from pydantic import BaseModel -from rdflib import XSD, Graph, URIRef +from rdflib import Graph, URIRef from rdflib.collection import Collection from rdflib.namespace import RDF from rdflib.term import BNode, Literal, Node @@ -12,6 +12,7 @@ from linkml_runtime.dumpers.dumper_root import Dumper from linkml_runtime.linkml_model import ElementName, PermissibleValue, SlotDefinition from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph +from linkml_runtime.utils.rdf_terms import value_term from linkml_runtime.utils.schemaview import SchemaView from linkml_runtime.utils.yamlutils import YAMLRoot @@ -102,20 +103,10 @@ def inject_triples( else: return Literal(element.text) if target_type in schemaview.all_types(): - t = schemaview.get_type(target_type) - dt_uri = t.uri - if dt_uri: - if dt_uri in ("rdfs:Resource", "xsd:anyURI"): - return URIRef(schemaview.expand_curie(element)) - elif dt_uri == "xsd:string": - return Literal(element) - else: - if "xsd" not in namespaces: - namespaces["xsd"] = XSD - return Literal(element, datatype=namespaces.uri_for(dt_uri)) - else: - logger.warning(f"No datatype specified for : {t.name}, using plain Literal") + if schemaview.induced_type(target_type).uri is None: + logger.warning(f"No datatype specified for : {target_type}, using plain Literal") return Literal(element) + return value_term(schemaview, target_type, element) element_vars = {k: v for k, v in vars(element).items() if not k.startswith("_")} if len(element_vars) == 0: id_slot = schemaview.get_identifier_slot(target_type) diff --git a/packages/linkml_runtime/src/linkml_runtime/loaders/rdflib_loader.py b/packages/linkml_runtime/src/linkml_runtime/loaders/rdflib_loader.py index 85c20605cc..dc3606d4cf 100644 --- a/packages/linkml_runtime/src/linkml_runtime/loaders/rdflib_loader.py +++ b/packages/linkml_runtime/src/linkml_runtime/loaders/rdflib_loader.py @@ -22,6 +22,7 @@ ) from linkml_runtime.loaders.loader_root import Loader from linkml_runtime.utils.formatutils import underscore +from linkml_runtime.utils.rdf_terms import TermKind, link_root, type_term from linkml_runtime.utils.schemaview import SchemaView from linkml_runtime.utils.yamlutils import YAMLRoot @@ -268,6 +269,8 @@ def _object_to_value( ) return o.value elif isinstance(o, BNode): + if self._range_term_kind(slot, schemaview) in (TermKind.BLANK_NODE, TermKind.BLANK_NODE_OR_IRI): + return f"_:{o}" if not is_inlined: logger.error(f"blank nodes should be inlined; {slot_name}={o} in {subject}") return Pointer(o) @@ -296,8 +299,11 @@ def _object_to_value( v = pv.text break elif TypeDefinition.class_name in range_applicable_elements: - if cast_literals: - v = namespaces.curie_for(o) + range_kind = self._range_term_kind(slot, schemaview) + if cast_literals or (range_kind is not None and range_kind.is_node): + # A uri value is a complete URI; values of other types may be written as CURIEs. + keep_iri = range_kind is not None and link_root(schemaview, slot.range) == "uri" + v = None if keep_iri else namespaces.curie_for(o) if v is None: v = str(o) logger.debug(f"Casting {o} to string") @@ -324,6 +330,13 @@ def _get_id_dict(self, node: VALID_SUBJECT, schemaview: SchemaView, cn: ClassDef raise Exception(f"Unexpected blank node {node}, type {cn} expects {id_slot.name} identifier") return {} + @staticmethod + def _range_term_kind(slot: SlotDefinition, schemaview: SchemaView) -> TermKind | None: + """Return the RDF term kind of the slot's values if its range is a type, else ``None``.""" + if slot.range in schemaview.all_types(): + return type_term(schemaview, slot.range).kind + return None + def _uri_to_id(self, node: VALID_SUBJECT, id_slot: SlotDefinition, schemaview: SchemaView) -> str: if schemaview.is_slot_percent_encoded(id_slot): return urllib.parse.unquote(node).replace(schemaview.namespaces()._base, "") diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py new file mode 100644 index 0000000000..73e8411234 --- /dev/null +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py @@ -0,0 +1,207 @@ +"""The RDF term that a value of a LinkML type becomes. + +A slot whose range is a type holds IRIs, blank nodes or literals, never a mix: +RDF keeps the three kinds of term distinct (RDF 1.1 Concepts §3.1), and OWL 2 DL +types every property as either an object or a data property (OWL 2 Structural +Specification §5.8.1). One rule decides the kind for the RDF generators and for +the RDF dumper and loader: + +1. A type whose datatype is a node kind has nodes of that kind. The node kinds + are those of ShEx (``shex:iri``, ``shex:bnode``, ``shex:nonliteral``; + ShEx 2.1 §5.4.2), which ``objectidentifier`` and ``nodeidentifier`` use, and + ``rdfs:Resource``, which marks a type whose values are nodes. +2. A type whose datatype is ``xsd:anyURI`` and that is ``uri`` or ``uriorcurie`` + from ``linkml:types``, or is derived from one of them, has IRIs. +3. Every other type has literals of its datatype. That includes a type derived + from ``string`` with ``uri: xsd:anyURI``: a URI reference held as text, which + may be relative (XSD 1.1 Part 2 §3.3.17), whereas an RDF IRI must be absolute + (RDF 1.1 Concepts §3.2). + +>>> from linkml_runtime.utils.schemaview import SchemaView +>>> sv = SchemaView(''' +... id: https://example.org/t +... name: t +... prefixes: +... linkml: https://w3id.org/linkml/ +... ex: https://example.org/t/ +... default_prefix: ex +... imports: [linkml:types] +... types: +... ImageLink: +... typeof: uri +... FilePath: +... typeof: string +... uri: xsd:anyURI +... ''') +>>> type_term(sv, "uri") +TypeTerm(kind=, datatype=None) +>>> type_term(sv, "ImageLink").kind + +>>> type_term(sv, "FilePath") +TypeTerm(kind=, datatype='http://www.w3.org/2001/XMLSchema#anyURI') +>>> type_term(sv, "nodeidentifier").kind + +>>> value_term(sv, "ImageLink", "ex:me.jpg") +rdflib.term.URIRef('https://example.org/t/me.jpg') +>>> value_term(sv, "FilePath", "./docs/cv.pdf") +rdflib.term.Literal('./docs/cv.pdf', datatype=rdflib.term.URIRef('http://www.w3.org/2001/XMLSchema#anyURI')) +""" + +import re +from dataclasses import dataclass +from enum import Enum +from functools import lru_cache +from typing import Any + +from rdflib import RDFS, XSD, BNode, Literal, URIRef +from rdflib.term import Node + +from linkml_runtime.linkml_model.types import SHEX +from linkml_runtime.utils.schemaview import SchemaView + + +class TermKind(Enum): + """The kind of RDF term a value becomes (RDF 1.1 Concepts §3.1).""" + + IRI = "iri" + BLANK_NODE = "bnode" + BLANK_NODE_OR_IRI = "nonliteral" + LITERAL = "literal" + + @property + def is_node(self) -> bool: + """Whether values of this kind are nodes (IRIs or blank nodes) rather than literals.""" + return self is not TermKind.LITERAL + + +# Datatype IRIs that name a kind of node rather than a literal datatype. ShEx +# spells the non-literal kind ``nonliteral``; ``linkml:types`` declares +# ``nodeidentifier`` with ``shex:nonLiteral``, so both spellings are listed. +NODE_KINDS: dict[str, TermKind] = { + str(SHEX.iri): TermKind.IRI, + str(SHEX.bnode): TermKind.BLANK_NODE, + str(SHEX.nonliteral): TermKind.BLANK_NODE_OR_IRI, + str(SHEX.nonLiteral): TermKind.BLANK_NODE_OR_IRI, + str(RDFS.Resource): TermKind.BLANK_NODE_OR_IRI, +} + +# The types of ``linkml:types`` whose values are IRIs, with the Python base their +# definitions declare. A link root is matched by name and definition: the name +# alone would admit a user type that happens to be called ``uri``, and +# ``from_schema`` cannot be used because merging imports rewrites it. +LINK_ROOTS: dict[str, str] = {"uri": "URI", "uriorcurie": "URIorCURIE"} + +# The vocabularies the built-in datatypes and the node kinds above come from. +# A schema may use their usual prefixes without declaring them; a declaration +# in the schema takes precedence. +VOCABULARY_PREFIXES: dict[str, str] = {"xsd": str(XSD), "shex": str(SHEX), "rdfs": str(RDFS)} + +# RFC 3986 §3.1: an absolute URI starts with a scheme followed by ":". +_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:") + + +@dataclass(frozen=True) +class TypeTerm: + """The RDF term kind of a type's values and, for literals, their datatype IRI.""" + + kind: TermKind + datatype: str | None = None + + +def _expand(sv: SchemaView, uri: str, type_name: str) -> str: + """Expand the datatype *uri* of *type_name*, a CURIE or an IRI, with the schema's prefixes. + + A declared prefix is expanded even when the local part contains a colon, which + ``SchemaView.expand_curie`` leaves alone. An IRI is recognised by ``://``; one of + another form, such as ``urn:``, needs its scheme declared as a prefix. + + :raises ValueError: if the prefix is neither declared nor one of :data:`VOCABULARY_PREFIXES` + """ + if "://" in uri: + return uri + prefix, _, local = uri.partition(":") + namespaces = sv.namespaces() + if prefix in namespaces: + return str(namespaces[prefix]) + local + if prefix in VOCABULARY_PREFIXES: + return VOCABULARY_PREFIXES[prefix] + local + raise ValueError(f"Type {type_name!r} has datatype {uri!r}, but the schema does not declare prefix {prefix!r}") + + +def expanded_datatype(sv: SchemaView, type_name: str) -> str | None: + """Return the expanded IRI of the datatype that *type_name* declares or inherits. + + :raises ValueError: if the datatype is a CURIE whose prefix the schema does not declare + """ + uri = sv.induced_type(type_name).uri + return _expand(sv, uri, type_name) if uri else None + + +@lru_cache(maxsize=4096) +def link_root(sv: SchemaView, type_name: str) -> str | None: + """Return the link root of ``linkml:types`` that *type_name* is or derives from, else ``None``. + + The link roots are ``uri``, whose values are complete URIs, and ``uriorcurie``, + whose values may be written as CURIEs. A root is matched by name and definition + (:data:`LINK_ROOTS`), through the ``typeof`` chain. + """ + for ancestor_name in sv.type_ancestors(type_name, reflexive=True): + base = LINK_ROOTS.get(ancestor_name) + if base is None: + continue + ancestor = sv.get_type(ancestor_name) + if ( + ancestor.typeof is None + and ancestor.base == base + and ancestor.uri is not None + and _expand(sv, ancestor.uri, ancestor_name) == str(XSD.anyURI) + ): + return ancestor_name + return None + + +@lru_cache(maxsize=4096) +def type_term(sv: SchemaView, type_name: str) -> TypeTerm: + """Return the RDF term kind of the values of the type *type_name* (see the module rules). + + :raises ValueError: if the type's datatype uses an undeclared prefix + """ + datatype = expanded_datatype(sv, type_name) + if datatype in NODE_KINDS: + return TypeTerm(NODE_KINDS[datatype]) + if datatype == str(XSD.anyURI) and link_root(sv, type_name) is not None: + return TypeTerm(TermKind.IRI) + return TypeTerm(TermKind.LITERAL, datatype) + + +def value_term(sv: SchemaView, type_name: str, value: Any) -> Node: + """Return the RDF term for *value* of the type *type_name*. + + IRIs are expanded from CURIEs. A value of a node type may name a blank node + (``_:b1``) when its kind allows blank nodes. A literal keeps the Python value, + so rdflib writes its canonical lexical form (``True`` becomes ``"true"``); + ``xsd:string`` values become plain literals, which RDF 1.1 gives that datatype. + + :raises ValueError: if a value of an IRI type is a relative reference, which + cannot be an RDF IRI (RDF 1.1 Concepts §3.2) + """ + term = type_term(sv, type_name) + if not term.kind.is_node: + if term.datatype is None or term.datatype == str(XSD.string): + return Literal(value) + return Literal(value, datatype=URIRef(term.datatype)) + text = str(value) + if text.startswith("_:") and term.kind in (TermKind.BLANK_NODE, TermKind.BLANK_NODE_OR_IRI): + return BNode(text[2:]) + if term.kind is TermKind.BLANK_NODE: + raise ValueError(f"Value {text!r} of type {type_name!r} must be a blank node label such as '_:b1'") + if text.startswith("_:"): + raise ValueError(f"Value {text!r} of type {type_name!r} is a blank node, but values of this type are IRIs") + iri = sv.expand_curie(text) + if not _SCHEME.match(iri): + raise ValueError( + f"Value {text!r} of type {type_name!r} is a relative reference, but values of this type are " + "IRIs, which must be absolute. Use a type derived from string with uri: xsd:anyURI to keep " + "URI references as text." + ) + return URIRef(iri) diff --git a/tests/linkml/test_base/__snapshots__/annotations.context.jsonld b/tests/linkml/test_base/__snapshots__/annotations.context.jsonld index cb67923cc6..84d8f7877d 100644 --- a/tests/linkml/test_base/__snapshots__/annotations.context.jsonld +++ b/tests/linkml/test_base/__snapshots__/annotations.context.jsonld @@ -8,7 +8,7 @@ "@id": "annotations" }, "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "tag" }, "value": { diff --git a/tests/linkml/test_base/__snapshots__/annotations.json b/tests/linkml/test_base/__snapshots__/annotations.json index bc51cec7c6..bffa4074a3 100644 --- a/tests/linkml/test_base/__snapshots__/annotations.json +++ b/tests/linkml/test_base/__snapshots__/annotations.json @@ -503,7 +503,7 @@ "@id": "annotations" }, "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "tag" }, "value": { diff --git a/tests/linkml/test_base/__snapshots__/extensions.context.jsonld b/tests/linkml/test_base/__snapshots__/extensions.context.jsonld index cebb3ee890..730a52a220 100644 --- a/tests/linkml/test_base/__snapshots__/extensions.context.jsonld +++ b/tests/linkml/test_base/__snapshots__/extensions.context.jsonld @@ -4,7 +4,7 @@ "linkml": "https://w3id.org/linkml/", "@vocab": "https://w3id.org/linkml/", "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "tag" }, "value": { diff --git a/tests/linkml/test_base/__snapshots__/extensions.json b/tests/linkml/test_base/__snapshots__/extensions.json index f9604e0ea6..6f7d53ac3a 100644 --- a/tests/linkml/test_base/__snapshots__/extensions.json +++ b/tests/linkml/test_base/__snapshots__/extensions.json @@ -442,7 +442,7 @@ "linkml": "https://w3id.org/linkml/", "@vocab": "https://w3id.org/linkml/", "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "tag" }, "value": { diff --git a/tests/linkml/test_base/__snapshots__/mappings.context.jsonld b/tests/linkml/test_base/__snapshots__/mappings.context.jsonld index b209ca8a52..e14c87bb34 100644 --- a/tests/linkml/test_base/__snapshots__/mappings.context.jsonld +++ b/tests/linkml/test_base/__snapshots__/mappings.context.jsonld @@ -12,35 +12,35 @@ "skos": "http://www.w3.org/2004/02/skos/core#", "@vocab": "https://w3id.org/linkml/", "broad_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:broadMatch" }, "close_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:closeMatch" }, "deprecated_element_has_exact_replacement": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "deprecated_element_has_exact_replacement" }, "deprecated_element_has_possible_replacement": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "deprecated_element_has_possible_replacement" }, "exact_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:exactMatch" }, "mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:mappingRelation" }, "narrow_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:narrowMatch" }, "related_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:relatedMatch" }, "@base": "https://w3id.org/linkml/" diff --git a/tests/linkml/test_base/__snapshots__/mappings.json b/tests/linkml/test_base/__snapshots__/mappings.json index babcbe09bd..02354810c1 100644 --- a/tests/linkml/test_base/__snapshots__/mappings.json +++ b/tests/linkml/test_base/__snapshots__/mappings.json @@ -486,35 +486,35 @@ "skos": "http://www.w3.org/2004/02/skos/core#", "@vocab": "https://w3id.org/linkml/", "broad_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:broadMatch" }, "close_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:closeMatch" }, "deprecated_element_has_exact_replacement": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "deprecated_element_has_exact_replacement" }, "deprecated_element_has_possible_replacement": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "deprecated_element_has_possible_replacement" }, "exact_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:exactMatch" }, "mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:mappingRelation" }, "narrow_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:narrowMatch" }, "related_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:relatedMatch" } }, diff --git a/tests/linkml/test_base/__snapshots__/meta.context.jsonld b/tests/linkml/test_base/__snapshots__/meta.context.jsonld index 9a33f0f10b..d51c9c8bf3 100644 --- a/tests/linkml/test_base/__snapshots__/meta.context.jsonld +++ b/tests/linkml/test_base/__snapshots__/meta.context.jsonld @@ -41,7 +41,7 @@ "@id": "skos:prefLabel" }, "contexts": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "contexts" }, "predicate": { @@ -68,7 +68,7 @@ "@id": "allowed" }, "source": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "dcterms:source" }, "description": { @@ -121,11 +121,11 @@ "@id": "boolean_slot" }, "broad_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:broadMatch" }, "categories": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "dcterms:subject" }, "children_are_mutually_disjoint": { @@ -133,7 +133,7 @@ "@id": "children_are_mutually_disjoint" }, "class_uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "class_uri" }, "classes": { @@ -145,11 +145,11 @@ "@id": "classification_rules" }, "close_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:closeMatch" }, "code_set": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "code_set" }, "code_set_tag": { @@ -162,7 +162,7 @@ "@id": "skos:note" }, "concepts": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "concepts" }, "conforms_to": { @@ -173,11 +173,11 @@ "@id": "consider_nulls_inequal" }, "contributors": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "dcterms:contributor" }, "created_by": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "pav:createdBy" }, "created_on": { @@ -203,18 +203,18 @@ "@id": "defining_slots" }, "definition_uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "definition_uri" }, "deprecated": { "@id": "deprecated" }, "deprecated_element_has_exact_replacement": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "deprecated_element_has_exact_replacement" }, "deprecated_element_has_possible_replacement": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "deprecated_element_has_possible_replacement" }, "derivation": { @@ -255,7 +255,7 @@ "@id": "enum_range" }, "enum_uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "enum_uri" }, "enums": { @@ -280,7 +280,7 @@ "@id": "equals_string_in" }, "exact_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:exactMatch" }, "exact_cardinality": { @@ -300,7 +300,7 @@ "@id": "examples" }, "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "tag" }, "value": { @@ -322,7 +322,7 @@ "@id": "framework" }, "from_schema": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:inScheme" }, "generation_date": { @@ -334,11 +334,11 @@ "@id": "has_member" }, "has_quantity_kind": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "qudt:hasQuantityKind" }, "id": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "id" }, "id_prefixes": { @@ -362,7 +362,7 @@ "@id": "ifabsent" }, "implements": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "implements" }, "implicit_prefix": { @@ -372,7 +372,7 @@ "@id": "import_as" }, "import_from": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "import_from" }, "import_map": { @@ -383,7 +383,7 @@ "@id": "imported_from" }, "imports": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "imports" }, "in_language": { @@ -426,7 +426,7 @@ "@id": "inlined_as_simple_dict" }, "instantiates": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "instantiates" }, "interpolated": { @@ -508,7 +508,7 @@ "@id": "type" }, "mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:mappingRelation" }, "matches": { @@ -527,7 +527,7 @@ "@id": "maximum_value" }, "meaning": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "meaning" }, "metamodel_version": { @@ -558,7 +558,7 @@ "@id": "mixins" }, "modified_by": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "oslc:modifiedBy" }, "multivalued": { @@ -567,7 +567,7 @@ }, "name": "@id", "narrow_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:narrowMatch" }, "none_of": { @@ -590,7 +590,7 @@ "@id": "open_world" }, "owned_by": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "owned_by" }, "owner": { @@ -624,7 +624,7 @@ "@id": "sh:prefix" }, "prefix_reference": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "sh:namespace" }, "prefixes": { @@ -632,7 +632,7 @@ "@id": "sh:declare" }, "publisher": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "dcterms:publisher" }, "pv_formula": { @@ -675,7 +675,7 @@ "@id": "reflexive_transitive_form_of" }, "related_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:relatedMatch" }, "relational_logical_characteristic": { @@ -691,7 +691,7 @@ "@id": "relational_role" }, "relationship_types": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "relationship_types" }, "repr": { @@ -717,7 +717,7 @@ "@id": "sh:rule" }, "see_also": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "rdfs:seeAlso" }, "setting_key": { @@ -754,7 +754,7 @@ "@id": "slot_names_unique" }, "slot_uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "slot_uri" }, "slot_usage": { @@ -773,11 +773,11 @@ "@id": "source_file_size" }, "source_nodes": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "source_nodes" }, "source_ontology": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "source_ontology" }, "status": { @@ -799,7 +799,7 @@ "@id": "structured_pattern" }, "subclass_of": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "subclass_of" }, "subproperty_of": { @@ -852,7 +852,7 @@ "@id": "type_mappings" }, "uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "uri" }, "typeof": { @@ -904,7 +904,7 @@ "@id": "value_specification_constant" }, "values_from": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "values_from" }, "version": { diff --git a/tests/linkml/test_base/__snapshots__/meta.json b/tests/linkml/test_base/__snapshots__/meta.json index acda6769d8..98b6fe7d0d 100644 --- a/tests/linkml/test_base/__snapshots__/meta.json +++ b/tests/linkml/test_base/__snapshots__/meta.json @@ -8951,7 +8951,7 @@ "@id": "skos:prefLabel" }, "contexts": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "contexts" }, "predicate": { @@ -8978,7 +8978,7 @@ "@id": "allowed" }, "source": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "dcterms:source" }, "description": { @@ -9031,11 +9031,11 @@ "@id": "boolean_slot" }, "broad_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:broadMatch" }, "categories": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "dcterms:subject" }, "children_are_mutually_disjoint": { @@ -9043,7 +9043,7 @@ "@id": "children_are_mutually_disjoint" }, "class_uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "class_uri" }, "classes": { @@ -9055,11 +9055,11 @@ "@id": "classification_rules" }, "close_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:closeMatch" }, "code_set": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "code_set" }, "code_set_tag": { @@ -9072,7 +9072,7 @@ "@id": "skos:note" }, "concepts": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "concepts" }, "conforms_to": { @@ -9083,11 +9083,11 @@ "@id": "consider_nulls_inequal" }, "contributors": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "dcterms:contributor" }, "created_by": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "pav:createdBy" }, "created_on": { @@ -9113,18 +9113,18 @@ "@id": "defining_slots" }, "definition_uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "definition_uri" }, "deprecated": { "@id": "deprecated" }, "deprecated_element_has_exact_replacement": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "deprecated_element_has_exact_replacement" }, "deprecated_element_has_possible_replacement": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "deprecated_element_has_possible_replacement" }, "derivation": { @@ -9165,7 +9165,7 @@ "@id": "enum_range" }, "enum_uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "enum_uri" }, "enums": { @@ -9190,7 +9190,7 @@ "@id": "equals_string_in" }, "exact_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:exactMatch" }, "exact_cardinality": { @@ -9210,7 +9210,7 @@ "@id": "examples" }, "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "tag" }, "value": { @@ -9232,7 +9232,7 @@ "@id": "framework" }, "from_schema": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:inScheme" }, "generation_date": { @@ -9244,11 +9244,11 @@ "@id": "has_member" }, "has_quantity_kind": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "qudt:hasQuantityKind" }, "id": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "id" }, "id_prefixes": { @@ -9272,7 +9272,7 @@ "@id": "ifabsent" }, "implements": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "implements" }, "implicit_prefix": { @@ -9282,7 +9282,7 @@ "@id": "import_as" }, "import_from": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "import_from" }, "import_map": { @@ -9293,7 +9293,7 @@ "@id": "imported_from" }, "imports": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "imports" }, "in_language": { @@ -9336,7 +9336,7 @@ "@id": "inlined_as_simple_dict" }, "instantiates": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "instantiates" }, "interpolated": { @@ -9418,7 +9418,7 @@ "@id": "type" }, "mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:mappingRelation" }, "matches": { @@ -9437,7 +9437,7 @@ "@id": "maximum_value" }, "meaning": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "meaning" }, "metamodel_version": { @@ -9468,7 +9468,7 @@ "@id": "mixins" }, "modified_by": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "oslc:modifiedBy" }, "multivalued": { @@ -9477,7 +9477,7 @@ }, "name": "@id", "narrow_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:narrowMatch" }, "none_of": { @@ -9500,7 +9500,7 @@ "@id": "open_world" }, "owned_by": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "owned_by" }, "owner": { @@ -9534,7 +9534,7 @@ "@id": "sh:prefix" }, "prefix_reference": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "sh:namespace" }, "prefixes": { @@ -9542,7 +9542,7 @@ "@id": "sh:declare" }, "publisher": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "dcterms:publisher" }, "pv_formula": { @@ -9585,7 +9585,7 @@ "@id": "reflexive_transitive_form_of" }, "related_mappings": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "skos:relatedMatch" }, "relational_logical_characteristic": { @@ -9601,7 +9601,7 @@ "@id": "relational_role" }, "relationship_types": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "relationship_types" }, "repr": { @@ -9627,7 +9627,7 @@ "@id": "sh:rule" }, "see_also": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "rdfs:seeAlso" }, "setting_key": { @@ -9664,7 +9664,7 @@ "@id": "slot_names_unique" }, "slot_uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "slot_uri" }, "slot_usage": { @@ -9683,11 +9683,11 @@ "@id": "source_file_size" }, "source_nodes": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "source_nodes" }, "source_ontology": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "source_ontology" }, "status": { @@ -9709,7 +9709,7 @@ "@id": "structured_pattern" }, "subclass_of": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "subclass_of" }, "subproperty_of": { @@ -9762,7 +9762,7 @@ "@id": "type_mappings" }, "uri": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "uri" }, "typeof": { @@ -9814,7 +9814,7 @@ "@id": "value_specification_constant" }, "values_from": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "values_from" }, "version": { diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld b/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld index 509e297b35..16621443df 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld @@ -639,7 +639,7 @@ "@id": "affects_response_to" }, "affiliation": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "affiliation" }, "agent_type": { @@ -783,7 +783,7 @@ "@id": "catalyzes" }, "category": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "category" }, "causal_mechanism_qualifier": { @@ -1737,7 +1737,7 @@ "@id": "mentions" }, "mesh_terms": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "mesh_terms" }, "missing_from": { @@ -1841,7 +1841,7 @@ "@id": "object_part_qualifier" }, "object_specialization_qualifier": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "object_specialization_qualifier" }, "occurs_in": { @@ -1868,7 +1868,7 @@ "@id": "original_object" }, "original_predicate": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "original_predicate" }, "original_subject": { @@ -1993,7 +1993,7 @@ "@id": "publications" }, "published_in": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "published_in" }, "publisher": { @@ -2082,7 +2082,7 @@ "@id": "resistance_associated_with" }, "resource_id": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "resource_id" }, "resource_role": { @@ -2260,7 +2260,7 @@ "@id": "subject_part_qualifier" }, "subject_specialization_qualifier": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "subject_specialization_qualifier" }, "summary": { @@ -2287,7 +2287,7 @@ "@id": "supporting_document_year" }, "supporting_documents": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "supporting_documents" }, "supporting_study_cohort": { @@ -2303,11 +2303,11 @@ "@id": "supporting_study_metadata" }, "supporting_study_method_description": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "supporting_study_method_description" }, "supporting_study_method_type": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "supporting_study_method_type" }, "supporting_study_size": { @@ -2412,7 +2412,7 @@ "@id": "update_date" }, "upstream_resource_ids": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "upstream_resource_ids" }, "url": { @@ -2437,7 +2437,7 @@ "@id": "xenologous_to" }, "xref": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "xref" }, "AccessibleDnaRegion": { diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.json b/tests/linkml/test_biolink_model/__snapshots__/biolink.json index 3080ce2608..02bc375a8f 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.json +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.json @@ -36634,7 +36634,7 @@ "@id": "affects_response_to" }, "affiliation": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "affiliation" }, "agent_type": { @@ -36778,7 +36778,7 @@ "@id": "catalyzes" }, "category": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "category" }, "causal_mechanism_qualifier": { @@ -37732,7 +37732,7 @@ "@id": "mentions" }, "mesh_terms": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "mesh_terms" }, "missing_from": { @@ -37836,7 +37836,7 @@ "@id": "object_part_qualifier" }, "object_specialization_qualifier": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "object_specialization_qualifier" }, "occurs_in": { @@ -37863,7 +37863,7 @@ "@id": "original_object" }, "original_predicate": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "original_predicate" }, "original_subject": { @@ -37988,7 +37988,7 @@ "@id": "publications" }, "published_in": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "published_in" }, "publisher": { @@ -38077,7 +38077,7 @@ "@id": "resistance_associated_with" }, "resource_id": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "resource_id" }, "resource_role": { @@ -38255,7 +38255,7 @@ "@id": "subject_part_qualifier" }, "subject_specialization_qualifier": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "subject_specialization_qualifier" }, "summary": { @@ -38282,7 +38282,7 @@ "@id": "supporting_document_year" }, "supporting_documents": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "supporting_documents" }, "supporting_study_cohort": { @@ -38298,11 +38298,11 @@ "@id": "supporting_study_metadata" }, "supporting_study_method_description": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "supporting_study_method_description" }, "supporting_study_method_type": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "supporting_study_method_type" }, "supporting_study_size": { @@ -38407,7 +38407,7 @@ "@id": "update_date" }, "upstream_resource_ids": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "upstream_resource_ids" }, "url": { @@ -38432,7 +38432,7 @@ "@id": "xenologous_to" }, "xref": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "xref" }, "AccessibleDnaRegion": { diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py index 72089eba44..6c56ab3fc5 100644 --- a/tests/linkml/test_generators/test_jsonldcontextgen.py +++ b/tests/linkml/test_generators/test_jsonldcontextgen.py @@ -4,11 +4,13 @@ import pytest from click.testing import CliRunner -from rdflib import Graph, Literal, URIRef +from rdflib import OWL, RDF, XSD, Graph, Literal, URIRef -from linkml.generators import ContextGenerator, JSONLDGenerator +from linkml.generators import ContextGenerator, JSONLDGenerator, OwlSchemaGenerator from linkml.generators.jsonldcontextgen import ContextGenerator as FrameContextGenerator from linkml.generators.jsonldcontextgen import cli as jsonld_context_cli +from linkml.generators.owlgen import cli as owl_cli +from linkml.utils.deprecation import EMITTED, XSD_ANYURI_AS_IRI_OPTION from tests.linkml.utils.compare_jsonld_context import CompareJsonldContext from tests.linkml.utils.validate_jsonld_context import RdfExpectations @@ -1019,376 +1021,132 @@ def test_exclude_external_imports_works_with_mergeimports_false(tmp_path): assert "ExternalCredential" not in ctx, "External class should be excluded with mergeimports=False" -def test_xsd_anyuri_as_iri_flag(): - """Test that --xsd-anyuri-as-iri maps uri ranges to @type: @id. - - By default, ``range: uri`` (type_uri ``xsd:anyURI``) produces - ``@type: xsd:anyURI`` (typed literal). With ``xsd_anyuri_as_iri=True``, - it produces ``@type: @id`` (IRI node reference), aligning the JSON-LD - context with the SHACL generator which already emits ``sh:nodeKind sh:IRI`` - for the same type. - - See: - - W3C SHACL §4.8.1 sh:nodeKind (https://www.w3.org/TR/shacl/#NodeKindConstraintComponent) - - JSON-LD 1.1 §4.2.2 Type Coercion (https://www.w3.org/TR/json-ld11/#type-coercion) - - RDF 1.1 §3.3 Literals vs §3.2 IRIs (https://www.w3.org/TR/rdf11-concepts/) - """ - schema_yaml = """ -id: https://example.org/test-uri-context -name: test_uri_context - -prefixes: - ex: https://example.org/ - linkml: https://w3id.org/linkml/ - -imports: - - linkml:types - -default_prefix: ex -default_range: string - -slots: - homepage: - range: uri - slot_uri: ex:homepage - node_ref: - range: nodeidentifier - slot_uri: ex:nodeRef - name: - range: string - slot_uri: ex:name - -classes: - Thing: - slots: - - homepage - - node_ref - - name -""" - # Default behaviour: uri → xsd:anyURI (backward compatible) - ctx_default = json.loads(ContextGenerator(schema_yaml).serialize())["@context"] - assert ctx_default["homepage"]["@type"] == "xsd:anyURI" - - # Opt-in: uri → @id (aligned with SHACL sh:nodeKind sh:IRI) - ctx_iri = json.loads(ContextGenerator(schema_yaml, xsd_anyuri_as_iri=True).serialize())["@context"] - assert ctx_iri["homepage"]["@type"] == "@id", ( - f"Expected @type: @id for uri range with xsd_anyuri_as_iri=True, got {ctx_iri['homepage'].get('@type')}" - ) - - # nodeidentifier is unaffected by the flag (not xsd:anyURI-typed) - # Its default @type depends on URI_RANGES matching shex:nonLiteral; - # we only verify the flag doesn't change its behaviour. - assert ctx_default["node_ref"]["@type"] == ctx_iri["node_ref"]["@type"] - - # string → no @type regardless of flag - assert "@type" not in ctx_default.get("name", {}) - assert "@type" not in ctx_iri.get("name", {}) - - -def test_xsd_anyuri_as_iri_with_any_of(): - """The --xsd-anyuri-as-iri flag must also apply to ``any_of`` slots - whose type branches include ``uri`` mixed with class ranges. - - ``_literal_coercion_for_ranges`` resolves mixed any_of type branches - and must use the extended URI_RANGES when the flag is active. - """ - schema_yaml = """ -id: https://example.org/test-anyof-uri -name: test_anyof_uri - -prefixes: - ex: https://example.org/ - linkml: https://w3id.org/linkml/ - -imports: - - linkml:types - -default_prefix: ex -default_range: string - -classes: - Container: - slots: - - mixed_slot - Target: - class_uri: ex:Target - -slots: - mixed_slot: - slot_uri: ex:mixed - any_of: - - range: Target - - range: uri -""" - # Default: mixed class+uri any_of — uri resolves to xsd:anyURI literal, - # which disagrees with @id from the class branch → no coercion emitted - ctx_default = json.loads(ContextGenerator(schema_yaml).serialize())["@context"] - default_type = ctx_default.get("mixed_slot", {}).get("@type") - assert default_type != "@id", f"Without flag, mixed any_of should not resolve to @id, got {default_type}" - - # With flag: uri branch now also resolves to @id, matching the class branch - # → all branches agree → @id is emitted - ctx_iri = json.loads(ContextGenerator(schema_yaml, xsd_anyuri_as_iri=True).serialize())["@context"] - assert ctx_iri["mixed_slot"]["@type"] == "@id", ( - f"Expected @id for mixed any_of with flag, got {ctx_iri.get('mixed_slot', {}).get('@type')}" - ) - - -def test_xsd_anyuri_as_iri_owl(): - """OWL generator must produce owl:ObjectProperty for uri ranges when flag is set. - - Without the flag, ``range: uri`` produces ``owl:DatatypeProperty`` with - ``rdfs:range xsd:anyURI``. With ``xsd_anyuri_as_iri=True``, it should - produce ``owl:ObjectProperty`` (no rdfs:range restriction), aligning - with the SHACL generator's ``sh:nodeKind sh:IRI``. - """ - from rdflib import OWL, RDF, URIRef - - from linkml.generators.owlgen import OwlSchemaGenerator - - schema_yaml = """ -id: https://example.org/test-owl-uri -name: test_owl_uri -prefixes: - ex: https://example.org/ - linkml: https://w3id.org/linkml/ -imports: - - linkml:types -default_prefix: ex -default_range: string -slots: - homepage: - range: uri - slot_uri: ex:homepage - name: - range: string - slot_uri: ex:name -classes: - Thing: - slots: - - homepage - - name -""" - # Default: uri → DatatypeProperty (must disable type_objects which - # unconditionally returns ObjectProperty for all type-ranged slots) - gen_default = OwlSchemaGenerator(schema_yaml, type_objects=False) - g_default = gen_default.as_graph() - homepage_uri = URIRef("https://example.org/homepage") - default_rdf_type = set(g_default.objects(homepage_uri, RDF.type)) - assert OWL.DatatypeProperty in default_rdf_type, ( - f"Without flag, homepage should be DatatypeProperty, got {default_rdf_type}" - ) - - # With flag: uri → ObjectProperty - gen_iri = OwlSchemaGenerator(schema_yaml, xsd_anyuri_as_iri=True, type_objects=False) - g_iri = gen_iri.as_graph() - iri_rdf_type = set(g_iri.objects(homepage_uri, RDF.type)) - assert OWL.ObjectProperty in iri_rdf_type, f"With flag, homepage should be ObjectProperty, got {iri_rdf_type}" - assert OWL.DatatypeProperty not in iri_rdf_type, ( - f"With flag, homepage should NOT be DatatypeProperty, got {iri_rdf_type}" - ) - - # String slot must remain DatatypeProperty regardless of flag - name_uri = URIRef("https://example.org/name") - name_rdf_type = set(g_iri.objects(name_uri, RDF.type)) - assert OWL.DatatypeProperty in name_rdf_type, f"String slot should remain DatatypeProperty, got {name_rdf_type}" - - -def test_xsd_anyuri_as_iri_uriorcurie_range(): - """``uriorcurie`` also maps to ``xsd:anyURI`` and must behave identically - to ``uri`` when the ``--xsd-anyuri-as-iri`` flag is active. - - This is a high-priority coverage gap: ``uriorcurie`` is distinct from - ``uri`` at the LinkML level but shares the same XSD type. - """ - schema_yaml = """ -id: https://example.org/test-uriorcurie -name: test_uriorcurie - +URI_TERMS_SCHEMA = """ +id: https://example.org/uri-terms +name: uri_terms prefixes: ex: https://example.org/ linkml: https://w3id.org/linkml/ - imports: - linkml:types - default_prefix: ex default_range: string - +types: + ImageLink: + typeof: uri + FilePath: + typeof: string + uri: xsd:anyURI slots: - reference: - range: uriorcurie - slot_uri: ex:reference - homepage: - range: uri - slot_uri: ex:homepage - + homepage: {range: uri} + related: {range: uriorcurie} + image: {range: ImageLink} + file_path: {range: FilePath} + node: {range: objectidentifier} + any_node: {range: nodeidentifier} + code: {range: curie} + name: {} + link_or_thing: + any_of: [{range: uri}, {range: Thing}] classes: Thing: - slots: - - reference - - homepage + attributes: + id: {identifier: true} + slots: [homepage, related, image, file_path, node, any_node, code, name, link_or_thing] + Special: + slots: [name] + slot_usage: + name: {range: uri} """ - ctx_default = json.loads(ContextGenerator(schema_yaml).serialize())["@context"] - assert ctx_default["reference"]["@type"] == "xsd:anyURI" - assert ctx_default["homepage"]["@type"] == "xsd:anyURI" - ctx_iri = json.loads(ContextGenerator(schema_yaml, xsd_anyuri_as_iri=True).serialize())["@context"] - assert ctx_iri["reference"]["@type"] == "@id", "uriorcurie should map to @id with xsd_anyuri_as_iri=True" - assert ctx_iri["homepage"]["@type"] == "@id", "uri should map to @id with xsd_anyuri_as_iri=True" +@pytest.mark.parametrize( + "slot,coercion", + [ + ("homepage", "@id"), + ("related", "@id"), + ("image", "@id"), + ("file_path", "xsd:anyURI"), + ("node", "@id"), + ("any_node", "@id"), + ("code", None), + ("name", None), + ("link_or_thing", "@id"), + ], +) +def test_type_coercion_follows_term_kind(slot, coercion): + """Slots get the coercion of their values' RDF term kind (``linkml_runtime.utils.rdf_terms``). -def test_xsd_anyuri_as_iri_curie_range_unchanged(): - """``curie`` maps to ``xsd:string`` (not ``xsd:anyURI``), so the flag - must NOT affect its coercion. - - This documents the cross-type boundary: ``uri`` and ``uriorcurie`` - share ``xsd:anyURI``, but ``curie`` uses ``xsd:string``. + ``uri``, ``uriorcurie``, types derived from them and the node types are IRIs + (``@id``); a string-derived ``xsd:anyURI`` type keeps URI references as typed + literals; ``curie`` and ``string`` are plain strings. """ - schema_yaml = """ -id: https://example.org/test-curie -name: test_curie - -prefixes: - ex: https://example.org/ - linkml: https://w3id.org/linkml/ - -imports: - - linkml:types - -default_prefix: ex -default_range: string - -slots: - curie_slot: - range: curie - slot_uri: ex:curieSlot - uri_slot: - range: uri - slot_uri: ex:uriSlot - -classes: - Thing: - slots: - - curie_slot - - uri_slot -""" - ctx_default = json.loads(ContextGenerator(schema_yaml).serialize())["@context"] - ctx_iri = json.loads(ContextGenerator(schema_yaml, xsd_anyuri_as_iri=True).serialize())["@context"] + ctx = json.loads(ContextGenerator(URI_TERMS_SCHEMA).serialize())["@context"] + assert ctx[slot].get("@type") == coercion - # curie (xsd:string) must be unaffected by the flag - curie_default = ctx_default.get("curie_slot", {}).get("@type") - curie_iri = ctx_iri.get("curie_slot", {}).get("@type") - assert curie_default == curie_iri, f"curie coercion should not change with flag: {curie_default} vs {curie_iri}" - # uri (xsd:anyURI) must change — sanity check - assert ctx_iri["uri_slot"]["@type"] == "@id" +def test_type_schemaview_on_both_generator_paths(): + """A SchemaView-based generator answers type questions from its own view; a SchemaLoader-based one builds one.""" + owl = OwlSchemaGenerator(URI_TERMS_SCHEMA) + assert owl.type_schemaview() is owl.schemaview + assert ContextGenerator(URI_TERMS_SCHEMA).type_schemaview().induced_type("ImageLink").uri == "xsd:anyURI" -def test_xsd_anyuri_as_iri_owl_curie_unchanged(): - """OWL generator must keep ``range: curie`` as DatatypeProperty even with flag. +def test_class_scoped_range_override_follows_term_kind(): + """A ``slot_usage`` override to a link type is coerced to ``@id`` in the class-scoped context.""" + ctx = json.loads(ContextGenerator(URI_TERMS_SCHEMA).serialize())["@context"] + assert ctx["Special"]["@context"]["name"]["@type"] == "@id" - ``curie`` maps to ``xsd:string`` (not ``xsd:anyURI``), so the - ``--xsd-anyuri-as-iri`` flag must not promote it to ObjectProperty. - This verifies cross-generator consistency: the JSON-LD context generator - already correctly excludes ``curie`` via ``URI_RANGES_WITH_XSD``; the - OWL generator must match via ``is_xsd_anyuri_range()``. - """ - from rdflib import OWL, RDF, URIRef - - from linkml.generators.owlgen import OwlSchemaGenerator - - schema_yaml = """ -id: https://example.org/test-owl-curie -name: test_owl_curie -prefixes: - ex: https://example.org/ - linkml: https://w3id.org/linkml/ -imports: - - linkml:types -default_prefix: ex -default_range: string -slots: - compact_id: - range: curie - slot_uri: ex:compactId - homepage: - range: uri - slot_uri: ex:homepage -classes: - Thing: - slots: - - compact_id - - homepage -""" - compact_id_uri = URIRef("https://example.org/compact_id") - homepage_uri = URIRef("https://example.org/homepage") - - # With flag: curie must stay DatatypeProperty, uri must become ObjectProperty - gen = OwlSchemaGenerator(schema_yaml, xsd_anyuri_as_iri=True, type_objects=False) - g = gen.as_graph() - curie_types = set(g.objects(compact_id_uri, RDF.type)) - assert OWL.DatatypeProperty in curie_types, f"curie slot must remain DatatypeProperty with flag, got {curie_types}" - assert OWL.ObjectProperty not in curie_types, ( - f"curie slot must NOT become ObjectProperty with flag, got {curie_types}" +def test_links_are_iris_and_uri_references_stay_text(): + """Through the generated context, links become IRIs and a relative file path stays an ``xsd:anyURI`` literal.""" + ctx = json.loads(ContextGenerator(URI_TERMS_SCHEMA).serialize()) + doc = { + **ctx, + "id": "ex:t1", + "homepage": "https://alice.example/", + "related": "ex:other", + "file_path": "./docs/cv.pdf", + } + graph = Graph().parse(data=json.dumps(doc), format="json-ld", base="https://reader.example/downloads/doc.json") + subject = URIRef("https://example.org/t1") + assert graph.value(subject, URIRef("https://example.org/homepage")) == URIRef("https://alice.example/") + assert graph.value(subject, URIRef("https://example.org/related")) == URIRef("https://example.org/other") + assert graph.value(subject, URIRef("https://example.org/file_path")) == Literal( + "./docs/cv.pdf", datatype=XSD.anyURI ) - # Sanity: uri must become ObjectProperty - uri_types = set(g.objects(homepage_uri, RDF.type)) - assert OWL.ObjectProperty in uri_types, f"uri slot should be ObjectProperty with flag, got {uri_types}" +@pytest.mark.parametrize("value", [True, False]) +@pytest.mark.parametrize("generator", [ContextGenerator, OwlSchemaGenerator]) +def test_xsd_anyuri_as_iri_is_deprecated_without_effect(generator, value): + """Passing the former option reports the deprecation and changes nothing, in either polarity.""" + EMITTED.discard(XSD_ANYURI_AS_IRI_OPTION) + flagged = generator(URI_TERMS_SCHEMA, xsd_anyuri_as_iri=value).serialize() + assert XSD_ANYURI_AS_IRI_OPTION in EMITTED + assert flagged == generator(URI_TERMS_SCHEMA).serialize() -def test_xsd_anyuri_as_iri_cli_flag(): - """Verify the ``--xsd-anyuri-as-iri`` flag is wired through Click.""" - import tempfile - from pathlib import Path - from click.testing import CliRunner +def _homepage_is_a_link_in_context(output: str) -> bool: + return json.loads(output)["@context"]["homepage"]["@type"] == "@id" - from linkml.generators.jsonldcontextgen import cli - schema_yaml = """ -id: https://example.org/test-cli -name: test_cli +def _homepage_is_a_link_in_owl(output: str) -> bool: + graph = Graph().parse(data=output, format="turtle") + return (URIRef("https://example.org/homepage"), RDF.type, OWL.ObjectProperty) in graph -prefixes: - ex: https://example.org/ - linkml: https://w3id.org/linkml/ - -imports: - - linkml:types - -default_prefix: ex -default_range: string -slots: - homepage: - range: uri - slot_uri: ex:homepage - -classes: - Thing: - slots: - - homepage -""" - with tempfile.TemporaryDirectory() as tmpdir: - schema_path = Path(tmpdir) / "test.yaml" - schema_path.write_text(schema_yaml) - - runner = CliRunner() - - # Without flag - result_default = runner.invoke(cli, [str(schema_path)]) - assert result_default.exit_code == 0, result_default.output - ctx_default = json.loads(result_default.output)["@context"] - assert ctx_default["homepage"]["@type"] == "xsd:anyURI" - - # With flag - result_iri = runner.invoke(cli, [str(schema_path), "--xsd-anyuri-as-iri"]) - assert result_iri.exit_code == 0, result_iri.output - ctx_iri = json.loads(result_iri.output)["@context"] - assert ctx_iri["homepage"]["@type"] == "@id" +@pytest.mark.parametrize( + "cli,homepage_is_a_link", + [(jsonld_context_cli, _homepage_is_a_link_in_context), (owl_cli, _homepage_is_a_link_in_owl)], +) +def test_xsd_anyuri_as_iri_cli_option_is_accepted(tmp_path, cli, homepage_is_a_link): + """Both command lines still accept the option, so existing build scripts keep working.""" + schema_path = tmp_path / "schema.yaml" + schema_path.write_text(URI_TERMS_SCHEMA, encoding="utf-8") + result = CliRunner().invoke(cli, [str(schema_path), "--xsd-anyuri-as-iri"]) + assert result.exit_code == 0, result.output + assert homepage_is_a_link(result.stdout) # Enum @type: @vocab context generation (JSON-LD 1.1 §4.2.3 + §4.1.8) diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index cf6764ad6d..e772ae9d26 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -400,7 +400,7 @@ def check_slot_default_value(slot: URIRef, default_value: Any, datatype: str = N # pyoxigraph's RDFC-1.0 serialization drops explicit ^^xsd:string # per RDF 1.1 (plain literals and xsd:string are equivalent). # Accept either form for xsd:string typed values. - expected = Literal(default_value, datatype=datatype) + expected = default_value if isinstance(default_value, URIRef) else Literal(default_value, datatype=datatype) if (subject, SH.defaultValue, expected) in g: return if datatype and str(datatype) == "http://www.w3.org/2001/XMLSchema#string": @@ -446,10 +446,10 @@ def check_slot_default_value(slot: URIRef, default_value: Any, datatype: str = N "2024-02-08T09:39:25", datatype=ShaclDataType.DATETIME.uri_ref, ) + # A uri value is an IRI (sh:nodeKind sh:IRI), so its default is one too. check_slot_default_value( URIRef("https://w3id.org/linkml/tests/kitchen_sink/ifabsent_uri"), - "https://w3id.org/linkml/tests/kitchen_sink/ifabsent_boolean", - datatype=ShaclDataType.URI.uri_ref, + URIRef("https://w3id.org/linkml/tests/kitchen_sink/ifabsent_boolean"), ) check_slot_default_value(URIRef("https://w3id.org/linkml/tests/kitchen_sink/ifabsent_not_literal"), "heartfelt") diff --git a/tests/linkml/test_generators/test_uri_term_kinds.py b/tests/linkml/test_generators/test_uri_term_kinds.py new file mode 100644 index 0000000000..e109603ff8 --- /dev/null +++ b/tests/linkml/test_generators/test_uri_term_kinds.py @@ -0,0 +1,323 @@ +"""Every generator and the RDF dumper give a URI-like value the same RDF term kind. + +The rule is ``linkml_runtime.utils.rdf_terms.type_term``: ``uri``, ``uriorcurie``, +the types derived from them and the node types (``objectidentifier``, +``nodeidentifier``, ``rdfs:Resource``, ``shex:bnode``) are IRIs or blank nodes; a type +derived from ``string`` with ``uri: xsd:anyURI`` keeps URI references as ``xsd:anyURI`` +literals; ``curie`` stays an ``xsd:string`` literal. +""" + +import json + +import pytest +import yaml +from pyshacl import validate +from rdflib import OWL, RDF, SH, XSD, BNode, Graph, Literal, URIRef + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.pythongen import PythonGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml.generators.shexgen import ShExGenerator +from linkml.generators.yarrrmlgen import YarrrmlGenerator +from linkml_runtime import SchemaView +from linkml_runtime.dumpers import rdflib_dumper +from linkml_runtime.loaders import rdflib_loader +from linkml_runtime.utils.compile_python import compile_python + +EX = "https://example.org/terms/" + +SCHEMA = """ +id: https://example.org/terms +name: terms +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/terms/ +default_prefix: ex +default_range: string +imports: + - linkml:types +types: + ImageLink: + typeof: uri + FilePath: + typeof: string + uri: xsd:anyURI + ResourceRef: + typeof: uriorcurie + uri: rdfs:Resource + BlankOnly: + typeof: string + uri: shex:bnode +classes: + Container: + tree_root: true + attributes: + documents: + range: Document + multivalued: true + inlined_as_list: true + Document: + class_uri: ex:Document + attributes: + id: + identifier: true + homepage: + range: uri + related: + range: uriorcurie + image: + range: ImageLink + file_path: + range: FilePath + node: + range: objectidentifier + any_node: + range: nodeidentifier + code: + range: curie + cross_ref: + range: ResourceRef + anon: + range: BlankOnly + title: + range: string +""" + +# slot -> (type, kind of its values): "iri", "nonliteral" (IRI or blank node), "bnode", or a literal datatype +SLOTS = { + "homepage": ("Uri", "iri"), + "related": ("Uriorcurie", "iri"), + "image": ("ImageLink", "iri"), + "file_path": ("FilePath", XSD.anyURI), + "node": ("Objectidentifier", "iri"), + "any_node": ("Nodeidentifier", "nonliteral"), + "code": ("Curie", XSD.string), + "cross_ref": ("ResourceRef", "nonliteral"), + "anon": ("BlankOnly", "bnode"), + "title": ("String", XSD.string), +} +# SHACL node kinds (§4.1.3) of the node term kinds +SH_NODE_KINDS = {"iri": SH.IRI, "nonliteral": SH.BlankNodeOrIRI, "bnode": SH.BlankNode} + +DATA = { + "id": "ex:d1", + "homepage": "https://example.org/terms/home", + "related": "ex:other", + "image": "https://example.org/terms/me.jpg", + "file_path": "./docs/cv.pdf", + "node": "ex:target", + "any_node": "ex:target", + "code": "ex:code", + "cross_ref": "ex:ref", + "anon": "_:n1", + "title": "Alice", +} + +EXPECTED_TERMS = { + "homepage": URIRef(EX + "home"), + "related": URIRef(EX + "other"), + "image": URIRef(EX + "me.jpg"), + "file_path": Literal("./docs/cv.pdf", datatype=XSD.anyURI), + "node": URIRef(EX + "target"), + "any_node": URIRef(EX + "target"), + "code": Literal("ex:code"), + "cross_ref": URIRef(EX + "ref"), + "anon": BNode("n1"), + "title": Literal("Alice"), +} + + +@pytest.fixture(scope="module") +def shapes() -> Graph: + return Graph().parse(data=ShaclGenerator(SCHEMA).serialize(), format="turtle") + + +@pytest.fixture(scope="module") +def module(): + return compile_python(PythonGenerator(SCHEMA).serialize()) + + +def _dumped(module, document: dict) -> Graph: + """The RDF of *document* written by the runtime RDF dumper.""" + container = module.Container(documents=[module.Document(**document)]) + return rdflib_dumper.as_rdf_graph(container, SchemaView(SCHEMA)) + + +def _via_context(document: dict) -> Graph: + """The RDF of *document* read through the generated JSON-LD context.""" + context = json.loads(ContextGenerator(SCHEMA).serialize())["@context"] + doc = {"@context": context, "documents": [{"@type": "Document", **document}]} + return Graph().parse(data=json.dumps(doc), format="json-ld", base="https://reader.example/downloads/doc.json") + + +@pytest.mark.parametrize("slot", SLOTS) +def test_json_ld_context(slot): + coercion = json.loads(ContextGenerator(SCHEMA).serialize())["@context"][slot].get("@type") + kind = SLOTS[slot][1] + expected = {"iri": "@id", "nonliteral": "@id", "bnode": "@id", XSD.anyURI: "xsd:anyURI", XSD.string: None}[kind] + assert coercion == expected + + +@pytest.mark.parametrize("slot", SLOTS) +def test_shacl(slot, shapes): + prop = next(shapes.subjects(SH.path, URIRef(EX + slot))) + node_kind, datatype = shapes.value(prop, SH.nodeKind), shapes.value(prop, SH.datatype) + kind = SLOTS[slot][1] + if kind in SH_NODE_KINDS: + assert (node_kind, datatype) == (SH_NODE_KINDS[kind], None) + else: + assert (node_kind, datatype) == (SH.Literal, kind) + + +def _owl_property(slot: str, **kwargs) -> tuple[set, set]: + """The OWL property types of *slot* and the fillers of its universal restrictions.""" + graph = OwlSchemaGenerator(SCHEMA, metaclasses=False, **kwargs).as_graph() + prop = URIRef(EX + slot) + fillers = {f for r in graph.subjects(OWL.onProperty, prop) for f in graph.objects(r, OWL.allValuesFrom)} + return set(graph.objects(prop, RDF.type)), fillers + + +@pytest.mark.parametrize("slot", SLOTS) +def test_owl(slot): + """Node-valued slots are object properties ranging over owl:Thing (OWL 2 §5.3, §5.8.1).""" + types, fillers = _owl_property(slot, type_objects=False) + kind = SLOTS[slot][1] + if kind in SH_NODE_KINDS: + assert (types, fillers) == ({OWL.ObjectProperty}, {OWL.Thing}) + else: + assert (types, fillers) == ({OWL.DatatypeProperty}, {kind}) + + +@pytest.mark.parametrize("slot", SLOTS) +def test_owl_type_objects_mode_is_unchanged(slot): + """With ``type_objects``, every typed value is an individual of the type's class, whatever its term kind.""" + types, fillers = _owl_property(slot, type_objects=True) + assert types == {OWL.ObjectProperty} + assert [str(f).rsplit("/", 1)[-1] for f in fillers] == [SLOTS[slot][0]] + + +@pytest.mark.parametrize("slot", SLOTS) +def test_shex(slot): + shapes = json.loads(ShExGenerator(SCHEMA, format="json").serialize())["shapes"] + type_name, kind = SLOTS[slot] + shape = next(s for s in shapes if s["id"].rsplit("/", 1)[-1] == type_name) + if kind in SH_NODE_KINDS: + assert shape.get("nodeKind") == kind + else: + assert shape.get("datatype") == str(kind) + + +@pytest.mark.parametrize("slot", SLOTS) +def test_yarrrml(slot): + mapping = yaml.safe_load(YarrrmlGenerator(SCHEMA).serialize())["mappings"]["Document"]["po"] + obj = next(po["o"] for po in mapping if po["p"] == f"ex:{slot}") + kind = SLOTS[slot][1] + if kind in SH_NODE_KINDS: + assert obj["type"] == {"iri": "iri", "nonliteral": "iri", "bnode": "blank"}[kind] + else: + assert obj["datatype"] == {XSD.anyURI: "xsd:anyURI", XSD.string: "xsd:string"}[kind] + + +@pytest.mark.parametrize("path", ["dumper", "context"]) +def test_both_data_paths_give_the_same_terms_and_conform(module, shapes, path): + """The dumper and the generated context write the same terms, and the generated shapes accept them. + + The relative file path stays text on both paths; it is not resolved against the reader's location. + """ + graph = _dumped(module, DATA) if path == "dumper" else _via_context(DATA) + subject = URIRef(EX + "d1") + assert {slot: graph.value(subject, URIRef(EX + slot)) for slot in SLOTS} == EXPECTED_TERMS + conforms, _, report = validate(graph, shacl_graph=shapes) + assert conforms, report + + +@pytest.mark.parametrize( + "slot,wrong", + [ + ("homepage", Literal(EX + "home", datatype=XSD.anyURI)), + ("image", Literal("ex:me.jpg")), + ("file_path", URIRef("https://example.org/cv.pdf")), + ("node", BNode("b1")), + ("any_node", Literal("ex:target", datatype=URIRef("http://www.w3.org/ns/shex#nonLiteral"))), + ("code", URIRef(EX + "code")), + ("cross_ref", Literal("ex:ref", datatype=URIRef("http://www.w3.org/2000/01/rdf-schema#Resource"))), + ("anon", URIRef(EX + "n1")), + ], +) +def test_shapes_reject_the_wrong_term_kind(module, shapes, slot, wrong): + graph = _dumped(module, DATA) + subject = URIRef(EX + "d1") + graph.set((subject, URIRef(EX + slot), wrong)) + conforms, _, report = validate(graph, shacl_graph=shapes) + assert not conforms, f"{slot}={wrong!r} accepted" + + +@pytest.mark.parametrize("cast_literals", [True, False]) +def test_dumped_terms_load_back(module, cast_literals): + """The loader accepts the dumper's terms, also in strict mode; ``uri`` values stay complete, CURIEs stay CURIEs.""" + graph = _dumped(module, DATA) + [container] = rdflib_loader.from_rdf_graph( + graph, schemaview=SchemaView(SCHEMA), target_class=module.Container, cast_literals=cast_literals + ) + loaded = {slot: getattr(container.documents[0], slot) for slot in SLOTS} + assert loaded == {slot: DATA[slot] for slot in SLOTS} + + +def test_loader_keeps_blank_node_labels(module): + """A blank node in an IRI-or-blank-node slot loads as its ``_:`` label instead of an inlined object.""" + graph = Graph() + container, document = BNode(), URIRef(EX + "d1") + graph.add((container, RDF.type, URIRef(EX + "Container"))) + graph.add((container, URIRef(EX + "documents"), document)) + graph.add((document, RDF.type, URIRef(EX + "Document"))) + graph.add((document, URIRef(EX + "cross_ref"), BNode("n1"))) + [container] = rdflib_loader.from_rdf_graph(graph, schemaview=SchemaView(SCHEMA), target_class=module.Container) + assert container.documents[0].cross_ref == "_:n1" + + +@pytest.mark.parametrize("slot", ["homepage", "image"]) +def test_dumper_rejects_relative_links(module, slot): + """A relative reference is not an RDF IRI (RDF 1.1 Concepts §3.2); text needs a string-derived type.""" + with pytest.raises(ValueError, match="relative reference"): + _dumped(module, {**DATA, slot: "../img/cover.png"}) + + +def test_blank_node_values_through_the_context(shapes): + """``_:`` labels in an IRI-or-blank-node slot become blank nodes and conform.""" + graph = _via_context({**DATA, "any_node": "_:n1", "cross_ref": "_:n2"}) + subject = URIRef(EX + "d1") + assert isinstance(graph.value(subject, URIRef(EX + "any_node")), BNode) + assert isinstance(graph.value(subject, URIRef(EX + "cross_ref")), BNode) + conforms, _, report = validate(graph, shacl_graph=shapes) + assert conforms, report + + +SUBPROPERTY_SCHEMA = """ +id: https://example.org/sub +name: sub +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/sub/ +default_prefix: ex +imports: + - linkml:types +slots: + related_to: {range: curie} + part_of: {is_a: related_to} +classes: + Edge: + class_uri: ex:Edge + attributes: + predicate: + range: curie + subproperty_of: related_to +""" + + +def test_subproperty_values_take_the_slot_term_kind(): + """``sh:in`` members are compared exactly (SHACL §4.8.3), so a ``curie`` slot lists CURIE literals.""" + shapes = Graph().parse(data=ShaclGenerator(SUBPROPERTY_SCHEMA).serialize(), format="turtle") + edge = '@prefix ex: . ex:e1 a ex:Edge ; ex:predicate "{}" .' + assert validate(Graph().parse(data=edge.format("ex:part_of"), format="turtle"), shacl_graph=shapes)[0] + assert not validate(Graph().parse(data=edge.format("ex:unrelated"), format="turtle"), shacl_graph=shapes)[0] diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld index 1ce86f3a57..3092b3dd07 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld @@ -10,7 +10,7 @@ "simple_uri_test": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/", "@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/", "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "linkml:tag" }, "value": { diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.model_only.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.model_only.context.jsonld index 4cf9c59701..9bd2b80f40 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.model_only.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.model_only.context.jsonld @@ -3,7 +3,7 @@ "xsd": "http://www.w3.org/2001/XMLSchema#", "@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/", "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "linkml:tag" }, "value": { diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld index 1ce86f3a57..3092b3dd07 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld @@ -10,7 +10,7 @@ "simple_uri_test": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/", "@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/", "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "linkml:tag" }, "value": { diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.model_only.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.model_only.context.jsonld index 4cf9c59701..9bd2b80f40 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.model_only.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.model_only.context.jsonld @@ -3,7 +3,7 @@ "xsd": "http://www.w3.org/2001/XMLSchema#", "@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/", "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "linkml:tag" }, "value": { diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld index d7274380da..ad8978b81f 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld @@ -10,7 +10,7 @@ "simple_uri_test": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/", "@vocab": "https://raw.githubusercontent.com/linkml/linkml/main/tests/test_scripts/input/simple_uri_test/", "tag": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "linkml:tag" }, "value": { diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.json b/tests/linkml/test_utils/__snapshots__/uriandcurie.json index 2c39326ff5..97198624f6 100644 --- a/tests/linkml/test_utils/__snapshots__/uriandcurie.json +++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.json @@ -196,7 +196,7 @@ "@id": "hasNcName" }, "hasURI": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "hasURI" }, "id": "@id", diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld b/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld index 47f2eb296b..f87f92da56 100644 --- a/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld +++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld @@ -15,7 +15,7 @@ "@id": "hasNcName" }, "hasURI": { - "@type": "xsd:anyURI", + "@type": "@id", "@id": "hasURI" }, "id": "@id", diff --git a/tests/linkml_runtime/test_utils/test_rdf_terms.py b/tests/linkml_runtime/test_utils/test_rdf_terms.py new file mode 100644 index 0000000000..92b08f9833 --- /dev/null +++ b/tests/linkml_runtime/test_utils/test_rdf_terms.py @@ -0,0 +1,202 @@ +"""Tests for the RDF term kind of values of LinkML types (``linkml_runtime.utils.rdf_terms``).""" + +import pytest +from rdflib import XSD, BNode, Literal, URIRef + +from linkml_runtime.utils.rdf_terms import TermKind, TypeTerm, link_root, type_term, value_term +from linkml_runtime.utils.schemaview import SchemaView + +ANYURI = str(XSD.anyURI) + +SCHEMA = """ +id: https://example.org/terms +name: terms +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/terms/ +default_prefix: ex +imports: + - linkml:types +types: + ImageLink: + typeof: uri + FilePath: + typeof: string + uri: xsd:anyURI + RootAnyUri: + uri: xsd:anyURI + base: str + ResourceRef: + typeof: uriorcurie + uri: rdfs:Resource + TextLink: + typeof: uri + uri: xsd:string + FullIriLink: + typeof: uri + uri: http://www.w3.org/2001/XMLSchema#anyURI + FullIriText: + typeof: string + uri: http://www.w3.org/2001/XMLSchema#anyURI + BlankOnly: + typeof: string + uri: shex:bnode +""" + + +@pytest.fixture(scope="module") +def sv() -> SchemaView: + return SchemaView(SCHEMA) + + +@pytest.mark.parametrize( + "type_name,expected", + [ + ("uri", TypeTerm(TermKind.IRI)), + ("uriorcurie", TypeTerm(TermKind.IRI)), + ("ImageLink", TypeTerm(TermKind.IRI)), + ("FullIriLink", TypeTerm(TermKind.IRI)), + ("objectidentifier", TypeTerm(TermKind.IRI)), + ("nodeidentifier", TypeTerm(TermKind.BLANK_NODE_OR_IRI)), + ("ResourceRef", TypeTerm(TermKind.BLANK_NODE_OR_IRI)), + ("BlankOnly", TypeTerm(TermKind.BLANK_NODE)), + ("FilePath", TypeTerm(TermKind.LITERAL, ANYURI)), + ("FullIriText", TypeTerm(TermKind.LITERAL, ANYURI)), + ("RootAnyUri", TypeTerm(TermKind.LITERAL, ANYURI)), + ("TextLink", TypeTerm(TermKind.LITERAL, str(XSD.string))), + ("curie", TypeTerm(TermKind.LITERAL, str(XSD.string))), + ("string", TypeTerm(TermKind.LITERAL, str(XSD.string))), + ("integer", TypeTerm(TermKind.LITERAL, str(XSD.integer))), + ], +) +def test_type_term(sv, type_name, expected): + """Links and node types are nodes; every other type, including text anyURI types, is a literal. + + An explicit datatype on a type derived from ``uri`` wins (``TextLink``), and the + spelling of the datatype IRI (CURIE or full IRI) does not matter. + """ + assert type_term(sv, type_name) == expected + + +def test_vocabulary_prefix_need_not_be_declared(sv): + """``ResourceRef`` uses ``rdfs:`` without declaring it; the vocabulary prefix is recognised.""" + assert "rdfs" not in sv.namespaces() + assert type_term(sv, "ResourceRef").kind is TermKind.BLANK_NODE_OR_IRI + + +def test_undeclared_prefix_fails_fast(): + """A datatype with a prefix that is neither declared nor a vocabulary prefix is an error.""" + sv = SchemaView(SCHEMA + " Odd:\n typeof: string\n uri: foo:bar\n") + with pytest.raises(ValueError, match="does not declare prefix 'foo'"): + type_term(sv, "Odd") + + +@pytest.mark.parametrize( + "prefix,uri,expected", + [ + ("ex: https://example.org/c/", "ex:a:b", "https://example.org/c/a:b"), + ("urn: 'urn:'", "urn:example:dt", "urn:example:dt"), + ], +) +def test_declared_prefix_expands_a_local_part_with_a_colon(prefix, uri, expected): + """A declared prefix expands whatever the local part, so a ``urn:`` datatype works once ``urn`` is declared.""" + sv = SchemaView( + f"id: https://example.org/c\nname: c\nprefixes:\n {prefix}\ntypes:\n Odd:\n base: str\n uri: {uri}\n" + ) + assert type_term(sv, "Odd") == TypeTerm(TermKind.LITERAL, expected) + + +@pytest.mark.parametrize( + "type_name,expected", + [ + ("uri", "uri"), + ("ImageLink", "uri"), + ("TextLink", "uri"), + ("FullIriLink", "uri"), + ("uriorcurie", "uriorcurie"), + ("ResourceRef", "uriorcurie"), + ("RootAnyUri", None), + ("FilePath", None), + ("objectidentifier", None), + ("curie", None), + ("string", None), + ], +) +def test_link_root(sv, type_name, expected): + """``uri`` values are complete URIs and ``uriorcurie`` values may be CURIEs; the root is found through ``typeof``.""" + assert link_root(sv, type_name) == expected + + +OWN_TYPES = """ +id: https://example.org/own +name: own +prefixes: + ex: https://example.org/own/ + xsd: http://www.w3.org/2001/XMLSchema# +default_prefix: ex +types: + string: + uri: xsd:string + base: str + uri: + typeof: string + uri: xsd:anyURI + uriorcurie: + uri: xsd:anyURI + base: URIorCURIE +""" + + +@pytest.mark.parametrize("type_name,kind", [("uri", TermKind.LITERAL), ("uriorcurie", TermKind.IRI)]) +def test_link_types_are_identified_by_their_definition(type_name, kind): + """A user type that is merely called ``uri`` is text; a verbatim copy of ``uriorcurie`` is a link.""" + assert type_term(SchemaView(OWN_TYPES), type_name).kind is kind + + +def test_merged_schema_keeps_link_types(): + """Merging imports rewrites ``from_schema``; the link types are still recognised.""" + sv = SchemaView(SCHEMA) + sv.merge_imports() + assert type_term(sv, "uri").kind is TermKind.IRI + assert type_term(sv, "ImageLink").kind is TermKind.IRI + + +@pytest.mark.parametrize( + "type_name,value,expected", + [ + ("uri", "https://example.org/x", URIRef("https://example.org/x")), + ("uriorcurie", "ex:x", URIRef("https://example.org/terms/x")), + ("objectidentifier", "ex:x", URIRef("https://example.org/terms/x")), + ("nodeidentifier", "_:b1", BNode("b1")), + ("ResourceRef", "_:b1", BNode("b1")), + ("FilePath", "./docs/cv.pdf", Literal("./docs/cv.pdf", datatype=XSD.anyURI)), + ("FilePath", "ex:x", Literal("ex:x", datatype=XSD.anyURI)), + ("curie", "ex:x", Literal("ex:x")), + ("string", "text", Literal("text")), + ("boolean", True, Literal(True)), + ], +) +def test_value_term(sv, type_name, value, expected): + """IRIs are expanded, text stays as written, and literals keep their native value.""" + term = value_term(sv, type_name, value) + assert term == expected + assert type(term) is type(expected) + + +def test_boolean_literal_has_canonical_lexical_form(sv): + assert value_term(sv, "boolean", True).n3() == '"true"^^' + + +@pytest.mark.parametrize( + "type_name,value,message", + [ + ("uri", "./docs/cv.pdf", "relative reference"), + ("ImageLink", "../img/a.png", "relative reference"), + ("uriorcurie", "_:b1", "is a blank node"), + ("BlankOnly", "https://example.org/x", "blank node label"), + ], +) +def test_value_term_rejects_values_of_the_wrong_kind(sv, type_name, value, message): + """A relative reference cannot be an RDF IRI (RDF 1.1 Concepts §3.2), nor a blank node an IRI.""" + with pytest.raises(ValueError, match=message): + value_term(sv, type_name, value) From 3327c5e556ad856996bf88cbc4fbaca4b6e5b31f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:56:42 +0000 Subject: [PATCH 2/5] build(deps-dev): bump typedb-driver from 3.13.6 to 3.14.0 Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- 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 5efe6aa04b..9485bf5559 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -118,7 +118,7 @@ tests-rustgen = [ "maturin>=1.15.0" ] typedb = [ - "typedb-driver>=3.13.6,<4.0", + "typedb-driver>=3.14.0,<4.0", ] bigquery = [ "sqlalchemy-bigquery >= 1.9.0", diff --git a/uv.lock b/uv.lock index 6dd5eb4030..b3bf0f4d57 100644 --- a/uv.lock +++ b/uv.lock @@ -2623,7 +2623,7 @@ tests-extra = [ { name = "testcontainers", specifier = "==4.15.0" }, ] tests-rustgen = [{ name = "maturin", specifier = ">=1.15.0" }] -typedb = [{ name = "typedb-driver", specifier = ">=3.13.6,<4.0" }] +typedb = [{ name = "typedb-driver", specifier = ">=3.14.0,<4.0" }] typing = [{ name = "numpydantic", specifier = ">=1.10.0" }] [[package]] @@ -5633,42 +5633,42 @@ wheels = [ [[package]] name = "typedb-driver" -version = "3.13.6" +version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parse" }, ] wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/b9/0d/082c30eaed39c313066bb486f1fe4acec9538f374c31462feb8676fd68f7/typedb_driver-3.14.0-py310-none-macosx_11_0_arm64.whl", hash = "sha256:a5a5007e46b73c9e4b742c7f88c82bffe6c30a569793438edd4ef4b6981769bb", size = 5628722, upload-time = "2026-10-21T19:40:00.929Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/8f8be9215567161c64eab4733763a3e1de7ef033c7cc06231a85d0d26e87/typedb_driver-3.14.0-py310-none-macosx_11_0_x86_64.whl", hash = "sha256:d3f75d9c41cd52150dadf442f20f1b347f45bbc67d042915cf8271b14ca58a66", size = 5766995, upload-time = "2026-10-21T19:54:15.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bc/8c84b8384592a7ccfb2fc14cda57bfcee35084a688a013db617d6df7c3fd/typedb_driver-3.14.0-py310-none-manylinux_2_17_aarch64.whl", hash = "sha256:afd10e637817b182e0f81c9aec9181e5231fd3f7b4c12c64937d882419575c65", size = 6996085, upload-time = "2026-10-21T19:45:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/ae/cd/2f20897be1edac15f62eb1819b34c6f739f029ba07b6d1b5ea3bdf87c368/typedb_driver-3.14.0-py310-none-manylinux_2_17_x86_64.whl", hash = "sha256:0aad76a52f26038101ea1bfda00c950924e274ba59ed69a2957aff1f68d17998", size = 7144980, upload-time = "2026-10-21T19:44:37.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/61/adc2b140e2f573e602fbb511f3e6093edb6199fc86d889d60f7d1b1fb40d/typedb_driver-3.14.0-py310-none-win_amd64.whl", hash = "sha256:33ef41c93713019ad867f633d38f8b2211296e87d7bf09aab5bdb47bf29c4647", size = 3416124, upload-time = "2026-10-21T19:47:08.061Z" }, + { url = "https://files.pythonhosted.org/packages/24/20/213000abfea897b86cba7ebfdd512c0961175e3b077e90fb79f1c47c5e28/typedb_driver-3.14.0-py311-none-macosx_11_0_arm64.whl", hash = "sha256:7dfe8309a23f0428ade286ff8bf59826f7d523b3c7c051c203ac3d208013eafd", size = 5628724, upload-time = "2026-10-21T19:40:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/10/54/9f58ecf4aea6ee5da6e2f4146bef68caf7f11be998c1e82915bf80ea207c/typedb_driver-3.14.0-py311-none-macosx_11_0_x86_64.whl", hash = "sha256:8f03ad8162c88373657d30b947025b9c50a7681fb1824654abe5653ec1b6d2ee", size = 5766986, upload-time = "2026-10-21T19:54:31.87Z" }, + { url = "https://files.pythonhosted.org/packages/c7/34/f08a1034ba9637399b8900eac04f4d606dbd9b4975064cc3c4a2298ecf9a/typedb_driver-3.14.0-py311-none-manylinux_2_17_aarch64.whl", hash = "sha256:a183d60583a42c0ed7f0360b2127782fa210ef145063711f8addcb4d98962f39", size = 6995975, upload-time = "2026-10-21T19:46:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/e1/55/4dc59d67134d1eee44d9ab1f093e5055a1c7a13ed461d78170cf6e17716d/typedb_driver-3.14.0-py311-none-manylinux_2_17_x86_64.whl", hash = "sha256:e994ead3d6289f79b586eefce5033426dee5df3310a414e9aa513a9c733a4215", size = 7144798, upload-time = "2026-10-21T19:44:56.444Z" }, + { url = "https://files.pythonhosted.org/packages/59/75/3dcbec09f2681fc53cd4e6bfcd723d3d4873ec1133e7558c3d9ae894d06e/typedb_driver-3.14.0-py311-none-win_amd64.whl", hash = "sha256:d3ee32bf824c8f542a690fbe01956e99c036887c247c95c98ea8a9040cb9f347", size = 3416395, upload-time = "2026-10-21T19:47:30.62Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/c35cff70246d9eeb80e527a5346711c2fa7fd42fe8e5f22071664295c469/typedb_driver-3.14.0-py312-none-macosx_11_0_arm64.whl", hash = "sha256:3934333b7fee9414372013ab36428334b9ff68ffa4c5129c40d1fae5f2753292", size = 5628454, upload-time = "2026-10-21T19:40:27.113Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/88c40b4010f5e9ff3ed2044146944de3f0ee0d3858d9a2c93d29567750c2/typedb_driver-3.14.0-py312-none-macosx_11_0_x86_64.whl", hash = "sha256:1ac9af0fcdc95fb7403f3a43fe86b822620ce5ed4d0031a2c228ae74b5372129", size = 5768440, upload-time = "2026-10-21T19:54:50.535Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/c65556a54c6d4a45780c1956febd8b4dd7845e02ebfc41f83db14a910498/typedb_driver-3.14.0-py312-none-manylinux_2_17_aarch64.whl", hash = "sha256:967fe12d13b828db770fe2c0392639c9ffd06c3668555077c1a57633eb476b62", size = 6997058, upload-time = "2026-10-21T19:46:40.255Z" }, + { url = "https://files.pythonhosted.org/packages/61/43/71bb4cf1fd9af94f55c02493eb11cfdd9308c11229b0595a7adfc0ed0667/typedb_driver-3.14.0-py312-none-manylinux_2_17_x86_64.whl", hash = "sha256:0deeed656a5d424d828dd76b1bb2b06802fe9218943cc74d8d22423bc947c9de", size = 7145424, upload-time = "2026-10-21T19:45:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/d5/45/fd6f4616e9351b30b4576696f4b1657669be825998e3887d280940359bb3/typedb_driver-3.14.0-py312-none-win_amd64.whl", hash = "sha256:af90c439557f9ddabcaff1b427d10ce5ca73a854a648a5bc9672c2b8a1b5602b", size = 3416725, upload-time = "2026-10-21T19:48:00.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c8/c3a4708280ce13d614b2610b7663b7ffca327bdf7ab3ecad9677ea1c1698/typedb_driver-3.14.0-py313-none-macosx_11_0_arm64.whl", hash = "sha256:9a346c1ba1d7f3e0bb67c3a43245771274dc91eb3f8bb79a2164dfff9008fc7f", size = 5628457, upload-time = "2026-10-21T19:40:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/1f/41/a3ceb2b0aff96183c6f5f8321f87702441c3ec843f4941b50923f75d6bb3/typedb_driver-3.14.0-py313-none-macosx_11_0_x86_64.whl", hash = "sha256:20822a387ce777cb7a2e7b9154f3228b373e55138362b671a50d8389eb6c67c2", size = 5768436, upload-time = "2026-10-21T19:55:07.724Z" }, + { url = "https://files.pythonhosted.org/packages/24/0f/126913412f4e51f857e3aff301d0a31ca72fc51de68a05cce06fa97297a1/typedb_driver-3.14.0-py313-none-manylinux_2_17_aarch64.whl", hash = "sha256:563c9a558aef03cc6ad8b25e63ae2e209ba6ac9e2a132ee765f8ae7e57649a78", size = 6996965, upload-time = "2026-10-21T19:46:58.81Z" }, + { url = "https://files.pythonhosted.org/packages/dc/50/c2aa75211c04f6df2d3777d4614a8913dfbde5e155481eccc5e7364ebe85/typedb_driver-3.14.0-py313-none-manylinux_2_17_x86_64.whl", hash = "sha256:d2c0fd4af532a0ec4f9ce16a8e486fff571bec19ec4a5ec50ab14541e6ab81b5", size = 7145401, upload-time = "2026-10-21T19:45:34.007Z" }, + { url = "https://files.pythonhosted.org/packages/a7/83/3772f8eb90077a9641f1ea6038809e55bb9ffba9f01c143d6ac15c09b6cb/typedb_driver-3.14.0-py313-none-win_amd64.whl", hash = "sha256:c2b9e9dbd8385eed8013107c19b70f85a31118cd0cd9cea7f222967e9ccb6228", size = 3415912, upload-time = "2026-10-21T19:49:01.4Z" }, + { url = "https://files.pythonhosted.org/packages/77/49/55524ebd26852b151e72d6f9a30b936aa9a6fe66bd82a8f2064a0cbddce8/typedb_driver-3.14.0-py314-none-macosx_11_0_arm64.whl", hash = "sha256:0c844aa98707dd856e4392ae02389359229525f0f3cafc272bbe191f45ca98e7", size = 5630183, upload-time = "2026-10-21T19:40:50.514Z" }, + { url = "https://files.pythonhosted.org/packages/4c/31/e7dc6226a9f8d51b3caaeda804666d0c3de59ff6b41cff916dff02df331d/typedb_driver-3.14.0-py314-none-macosx_11_0_x86_64.whl", hash = "sha256:f248d317938ea472f575ddaa5d9acc8240969e3f739312f82a8ec8b60915c75a", size = 5769544, upload-time = "2026-10-21T19:55:24.852Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/0adbedf50d281a238afa843efa50bf6fd49f7e7b321c3e8d14382dc48596/typedb_driver-3.14.0-py314-none-manylinux_2_17_aarch64.whl", hash = "sha256:9f70d999a3fc2cba59942fbf4f527f3f009d564e3ff119b7c29b62f4ddf47d9c", size = 6997150, upload-time = "2026-10-21T19:47:17.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/96b70b5a90c502a20d9b805a77779624b5e0b6673436f87548533f46985f/typedb_driver-3.14.0-py314-none-manylinux_2_17_x86_64.whl", hash = "sha256:4ab292908632c846c8db16edc38fb0c6721f4d29a4cad8e07282ef4f4dd1cde8", size = 7144964, upload-time = "2026-10-21T19:45:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/e8bc16f0abdbd0b5cfe6754f81c89a480e498867eb13fc060bb128a56070/typedb_driver-3.14.0-py314-none-win_amd64.whl", hash = "sha256:33336406bba49ba8b1144c9aeec5f9aee4768f6302b99150419d190ffaea38aa", size = 3415655, upload-time = "2026-10-21T19:49:44.775Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/8f9d34698e251de8d9ec87434b4dea01c9975fa73afa3823574645b6ca21/typedb_driver-3.14.0-py39-none-macosx_11_0_arm64.whl", hash = "sha256:54b6cd1e7d367f8a119aac5485fa5f2bf583f3a5d388be3705998a33f1bf9e02", size = 5628655, upload-time = "2026-10-21T19:39:48.68Z" }, + { url = "https://files.pythonhosted.org/packages/58/3c/52d004f7cdb9cd16ff2c5cb3d7006d7dc77868509d64c832bb3c888f9fef/typedb_driver-3.14.0-py39-none-macosx_11_0_x86_64.whl", hash = "sha256:2baa6b217f02c9ffa5f64ed7869f148286023e678c516efb71d96f31074baa4c", size = 5767088, upload-time = "2026-10-21T19:53:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/e6f28fe733fde91082fef5d8304db8027fd98e9c23ae2d4726b764849be9/typedb_driver-3.14.0-py39-none-manylinux_2_17_aarch64.whl", hash = "sha256:2fc73d8da0edf637bb8313ba6a0bd2748bfa200f3bf1e8ed5e766b878c95c744", size = 6996040, upload-time = "2026-10-21T19:45:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/930a608b2246d5f1c1e4ca8976da92aa9d941e8da936149532b156f33662/typedb_driver-3.14.0-py39-none-manylinux_2_17_x86_64.whl", hash = "sha256:190922028db87dcc3072d0bf4155f2c59ec31d07791c2c503e482c768254548b", size = 7144856, upload-time = "2026-10-21T19:44:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/41/c0/5fea8954890b28813973ba134d4fe8121bc4ef9f29f7bb2acdfa4a68cf1d/typedb_driver-3.14.0-py39-none-win_amd64.whl", hash = "sha256:69d4e38735a667dc95964c4413509a2e63f3feb3761361fc700d5fdb268f4cbf", size = 3415586, upload-time = "2026-10-21T19:46:34.843Z" }, ] [[package]] From 81ae07dabcbb440cae7ed647d4f69cab47f8e362 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:13:03 +0000 Subject: [PATCH 3/5] build(deps): bump pyoxigraph (#4191) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/linkml_runtime/pyproject.toml | 2 +- uv.lock | 74 +++++++++++++------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index 5c92c3a82f..f97884d34b 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "requests", "prefixmaps >=0.1.4", "curies>=0.14.6", - "pyoxigraph>=0.5.12", + "pyoxigraph>=0.5.13", "pydantic>=2.14.1,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] diff --git a/uv.lock b/uv.lock index b3bf0f4d57..f68c1dc180 100644 --- a/uv.lock +++ b/uv.lock @@ -2677,7 +2677,7 @@ requires-dist = [ { name = "prefixcommons", specifier = ">=0.1.12" }, { name = "prefixmaps", specifier = ">=0.1.4" }, { name = "pydantic", specifier = ">=2.14.1,<3.0.0" }, - { name = "pyoxigraph", specifier = ">=0.5.12" }, + { name = "pyoxigraph", specifier = ">=0.5.13" }, { name = "pyyaml" }, { name = "rdflib", specifier = ">=7.6.0" }, { name = "requests" }, @@ -4242,42 +4242,42 @@ wheels = [ [[package]] name = "pyoxigraph" -version = "0.5.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/00/d1d29089963a4863faf34d0659a5797c23503f1000b35202f15856a016b2/pyoxigraph-0.5.12.tar.gz", hash = "sha256:e1932585267dadeebc11ad97985a916e2cf66df81abb1e335495ed0ce2d5f0ed", size = 5300947, upload-time = "2026-10-18T20:01:58.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/ae/75160e5640100adda56ce53b834ff8c917da56d521fab6003b176868bab4/pyoxigraph-0.5.12-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:459c856ac2a1824d5a77fc5d07ae9867da09bb303ebafc019499e7d313a6671d", size = 7698462, upload-time = "2026-10-18T20:00:53.552Z" }, - { url = "https://files.pythonhosted.org/packages/b7/76/65f7c66b770ed27037c6ad25f67a6ffa035c3673994b2fad33d655b019ae/pyoxigraph-0.5.12-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d986e58ff4fc00b346acf79e11a1ce3eb4b5e3741226379d4c1260a0dd6f910e", size = 8232669, upload-time = "2026-10-18T20:00:55.665Z" }, - { url = "https://files.pythonhosted.org/packages/46/53/49a3764effc0562f3f2b4e05343b288c89a38acea22bc86f777f229348dd/pyoxigraph-0.5.12-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7817619a09d044f2a34507fe834075b3485a61fe81c1fe56cfe44e7152fcffab", size = 7698871, upload-time = "2026-10-18T20:00:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/e0/da/0bc18ed4774a0bb5353c091a0b4c254ba37bd971b30d7d4ca5c8b4fed754/pyoxigraph-0.5.12-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:28edbc08004080c17872d9b49a9be8a31e1678c2a9ca1c00afdb2f7aaf5c70ad", size = 8231660, upload-time = "2026-10-18T20:00:59.58Z" }, - { url = "https://files.pythonhosted.org/packages/35/ce/10abfc263330577320188ca0cfaed4cd429f6d13d637698f8ca18858731c/pyoxigraph-0.5.12-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4764a0cd745f1a3d310432a637b3ab4bc7eaaa24e294ec37b0e2a4e04d7d0ea5", size = 7700530, upload-time = "2026-10-18T20:01:01.299Z" }, - { url = "https://files.pythonhosted.org/packages/c8/68/262312b07ee1089bf929eeeb22f89967d9503b080c670ea665482111f462/pyoxigraph-0.5.12-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3c00ebb1289505cf79ee0336bae2df84f873be33f769d8f48e3d063cc4eeb60b", size = 8233189, upload-time = "2026-10-18T20:01:03.275Z" }, - { url = "https://files.pythonhosted.org/packages/7e/78/3e43c27d981f2ec549b9700e4d60d001f5ab4631806f1fa485556fa2c93f/pyoxigraph-0.5.12-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:fcb9991d19f8f6566ef800c6016b566d60f7a38d4076bab7e0fbd3add32af627", size = 7699716, upload-time = "2026-10-18T20:01:05.048Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/edd0398096eda0522f4f49b5de50f40a7f9278dbef28aa7ba290e7454ce4/pyoxigraph-0.5.12-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b59c72eae73d4e66c6a687a510133a54ebce3e3ebbdb7c69dfa7e4de36504427", size = 8232847, upload-time = "2026-10-18T20:01:07.208Z" }, - { url = "https://files.pythonhosted.org/packages/07/97/32ed238d27872a724676f84b590c2ab64d64726c0f8105fc2a89f10ca268/pyoxigraph-0.5.12-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0077b688f6fc9cf3db4f3eee7acb5153734e958bee2be4ad7345121332c2bdfe", size = 7694160, upload-time = "2026-10-18T20:01:08.986Z" }, - { url = "https://files.pythonhosted.org/packages/3c/15/6dd1844c2f09b963303203a8f531c7cfc48cfa548beff5c19293814cf4be/pyoxigraph-0.5.12-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ad18580dc0e4ee4afb38e10f1e80f536a0a92194455b7f8c964f11fd0c64a8c9", size = 8226954, upload-time = "2026-10-18T20:01:10.982Z" }, - { url = "https://files.pythonhosted.org/packages/3a/33/6b9ccc2b9c91e8f5e59913be756483da61e98c598ed2c70267b473719562/pyoxigraph-0.5.12-cp315-abi3.abi3t-macosx_10_14_x86_64.whl", hash = "sha256:63bbb2c36f72aafa28424d6048336a6a6799236dcb80f48d3c8f00e3b7c07bfa", size = 6375614, upload-time = "2026-10-18T20:01:12.729Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7a/650f2305517f193095bc5fb9069ed38044d26516c18502ad60e8c871b9dd/pyoxigraph-0.5.12-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:2f190eb24aae604dcf2739cb434c0ce87c1eafcb220de86675b1d59c73b739ca", size = 5864298, upload-time = "2026-10-18T20:01:14.439Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6a/1e318f595e5569cc0e9b5f3560f01a137a598ed1ae287a1320913bb1bb8c/pyoxigraph-0.5.12-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:fa44ed2c1b0b73a42398605534a22807762959ded229c04c7994d35777f715a7", size = 7732898, upload-time = "2026-10-18T20:01:16.503Z" }, - { url = "https://files.pythonhosted.org/packages/20/30/ef64fb7a02f5ffdfb4eb214a87995bdbbb8c4857ac6a7c454f7877d30297/pyoxigraph-0.5.12-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:060491a16a76b4dd617ceff85a25827a84183407f95308be0d5874f0df513ff2", size = 8266606, upload-time = "2026-10-18T20:01:18.42Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/b6ce713cac64dfe33a4beeb8e1f30f61a109a1e9c374de5aca5f5febadcb/pyoxigraph-0.5.12-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:5ac5d9fbfe61a3cd7551b421ce54bbd5bfd2aacf23883491594830981b76a5b9", size = 8924250, upload-time = "2026-10-18T20:01:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a1/3a29665bc035be550876890457931d6fde8940880a53dc3e7348d68cfdae/pyoxigraph-0.5.12-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:87459392fa087a14c2372826cb137f93ddfde4e7e566502619ebcf30c190ecf3", size = 9490513, upload-time = "2026-10-18T20:01:22.588Z" }, - { url = "https://files.pythonhosted.org/packages/9f/85/b06ab1e215a120924605e2f3aed723f81d568aaacd5e435cf8403b49f3ae/pyoxigraph-0.5.12-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:e8cd3698abb52e0be45db9f74827e65d5b4268b990e58a87b61c2b671c83279f", size = 5495177, upload-time = "2026-10-18T20:01:24.808Z" }, - { url = "https://files.pythonhosted.org/packages/63/03/b8c4cd224569f3ff53ec946928394f14116ef5e849cfaad6c46b562f3c77/pyoxigraph-0.5.12-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:0bc1816d12c63c5500eab5825d2d376ed55d86b66b5bffb3967daf51738bd4aa", size = 5144209, upload-time = "2026-10-18T20:01:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/2e/21/f115cd9d4151f60fd2e96fadfd7103bae08d46493f6a016fb403d8483066/pyoxigraph-0.5.12-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ae8c01d2789d3e42810b8965ea18024689b9a76c54c007d00c7819ea6471bd8f", size = 7699015, upload-time = "2026-10-18T20:01:28.752Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ac/e09a14d2f8046481a8a926f4b6a610749d95b3fc0f585d4aabbd03cdfa29/pyoxigraph-0.5.12-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:2169f6d692b79f0ef2ab3a4f1c217a5a350a7b365d61db8b862296ef5c0ef04c", size = 8232403, upload-time = "2026-10-18T20:01:30.979Z" }, - { url = "https://files.pythonhosted.org/packages/04/3d/00674b72b2b2163eece7ebf3ed7f1740bf4275c5503c1342fafb029ca71e/pyoxigraph-0.5.12-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:11f069241d0be167a473c44f6d3184cd7560af771edb83c0d7f7c19d8c2acb94", size = 7695279, upload-time = "2026-10-18T20:01:32.993Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c0/194e4ace63625c153a1fa880887b2d250978f9a0581df19e0f98406b3ed2/pyoxigraph-0.5.12-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:8e105308a4c76ce0f98e014833f8b4181b82ada18b02900d687e57af954f8fbd", size = 8227289, upload-time = "2026-10-18T20:01:34.962Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/dc406eecec6ff6ae6b0f122a294784799a0f2e4d69c22ce460126fac3fb9/pyoxigraph-0.5.12-cp39-abi3-macosx_10_14_x86_64.whl", hash = "sha256:1a0c8931365df07fd77d5f6f7997bf0716ed5a7148d40aee2a6513101ecb6609", size = 6343151, upload-time = "2026-10-18T20:01:37.112Z" }, - { url = "https://files.pythonhosted.org/packages/d9/29/674774cc216bd671329ec1b5b60cded69c2566b40cef43eefe48de622099/pyoxigraph-0.5.12-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:b828b0e2a00c9d14725181d65677074a25f5f87e53eaf6bd89cfc772bbcf94b5", size = 5837673, upload-time = "2026-10-18T20:01:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/a0/24/9210f9d4b359347e8b4e1e484e11122646cabe8b3d64db65b4c13d9c372b/pyoxigraph-0.5.12-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9ed87da388d6cbe3ae41b4f5071c5890957d434afab872b104349a322f985e48", size = 7701997, upload-time = "2026-10-18T20:01:41.116Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/259a7296568de62f144937364d7b3e3baccfbe82ec726f13c245da2f8f7a/pyoxigraph-0.5.12-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f19c69a212771ad5368f51061731eec847cd8aa7f3075de9beee728e5c82826", size = 8234885, upload-time = "2026-10-18T20:01:43.607Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b3/39eb47fff5442b9e762bae09eb7c2c496809f2b0090bdc5cb569e8f572b5/pyoxigraph-0.5.12-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2d968d0d45f1f38c02a4417b3eebba3e830b2556732a67f2a528832daca10805", size = 8889557, upload-time = "2026-10-18T20:01:45.888Z" }, - { url = "https://files.pythonhosted.org/packages/a6/63/f9dd746e4095bc0704caa8ceae48b448cc3c44abdb76ebbca14cd7019166/pyoxigraph-0.5.12-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d89ef734fa4a51055bd234f1812c344fbaed7bb97d51516730cba5a517a6bac5", size = 9458815, upload-time = "2026-10-18T20:01:48.616Z" }, - { url = "https://files.pythonhosted.org/packages/7a/bf/8d9356c985516eb3c0fef581729875d01c9201a93d87c629c83d85988290/pyoxigraph-0.5.12-cp39-abi3-win_amd64.whl", hash = "sha256:41a146a5b1346c5289d06fc9c789ee045092a797a6d68b687e2afaa1714cfd2a", size = 5470372, upload-time = "2026-10-18T20:01:51.165Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2a/69dbd598932b509b25d6152fe4b2722c955ecc744e61e36e76e7de61fdd3/pyoxigraph-0.5.12-cp39-abi3-win_arm64.whl", hash = "sha256:fe34a10e262f93b4642f6e888aaa15aefd7b430c7c312ace511ccd01b1350a53", size = 5124124, upload-time = "2026-10-18T20:01:52.986Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1f/1c0c60fe2853e9d8a35dc5058d37d7e407913973cbec2d355bca38246886/pyoxigraph-0.5.12-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9bad78abe9ecd11eec930bf5626736db605e3352313d1f341a60e668c7081bc4", size = 7706736, upload-time = "2026-10-18T20:01:54.838Z" }, - { url = "https://files.pythonhosted.org/packages/45/ca/b2a949a950439178ad336f8728ca084450213383470451d1b1544d0b44d4/pyoxigraph-0.5.12-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:241c9011e4b3d35c92c4c4bb5ab8c371a26940015551327e41b66cec724836c7", size = 8243753, upload-time = "2026-10-18T20:01:56.86Z" }, +version = "0.5.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/c0/1e3703a8d0d084c1ecb4508e3f48d66a84b6809bc20f51d63be6492c8ea1/pyoxigraph-0.5.13.tar.gz", hash = "sha256:1805b92b51293f658557ae53b4eb42ac4dc826959ccff1c5c0f5067ac570b073", size = 5310796, upload-time = "2026-10-22T19:30:25.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/ba/244c2d17ab761da7820c3c76e96d3668c4f97e1a2bf8a960c0183172e8d7/pyoxigraph-0.5.13-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f4c81f3588e6a417a537e1155785e99331dd3e03c4362e0688d21963c2fcc0b1", size = 7702315, upload-time = "2026-10-22T19:29:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/b44693479e601405df8a57c6866dd245c307c2df361f5dc5e81691301781/pyoxigraph-0.5.13-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:20eeebb6ead914eb7030870b6d7e43f854c7a556cb75ce0375e12aa3bf4a90e5", size = 8236538, upload-time = "2026-10-22T19:29:15.238Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f5/2ed07d264ff3d58454ec92a3641c60de07e96bd2f94e9000ba7173d6cb6a/pyoxigraph-0.5.13-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9f4ab8a252f198e9d414be38886bf54018d69cd0719c2d14d470c096e646bda6", size = 7704054, upload-time = "2026-10-22T19:29:17.677Z" }, + { url = "https://files.pythonhosted.org/packages/98/72/9f2e48bb184dfed90bb01c1933470e80e1b1039114bf045da02b63aaff9f/pyoxigraph-0.5.13-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:afb4d4ac972c17da77b5f5f17fd1b20f3a95d6a56c7dc6815bf701d1e4a0d255", size = 8236072, upload-time = "2026-10-22T19:29:19.517Z" }, + { url = "https://files.pythonhosted.org/packages/4e/aa/7c08248418af31730019cd9eab3b1fd0a3aa50a3b2a802d5c9af08f2a387/pyoxigraph-0.5.13-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:7ad11cc64424fffb65e407ad6a88ce1483c8597cf196788aa2e4b89287eef860", size = 7704303, upload-time = "2026-10-22T19:29:21.62Z" }, + { url = "https://files.pythonhosted.org/packages/79/67/85a196a525a3ec8b5075ae5c750df9cb29e6a44fa5aa61508c203c156aef/pyoxigraph-0.5.13-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:402f8bf9eae3b37a18e808bde1b39760a1e15f208bb0e1a59c796567e4aa2aa0", size = 8237211, upload-time = "2026-10-22T19:29:23.967Z" }, + { url = "https://files.pythonhosted.org/packages/c5/82/0a0c603742897b2458710ad74e7601fb0240ecf3225f7bdcdd176c26e2b3/pyoxigraph-0.5.13-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:be4d0f0ba2e054fa1926cc962611a66f2a52ef2d65f6b7161e1a4af2880e2a96", size = 7703302, upload-time = "2026-10-22T19:29:26.23Z" }, + { url = "https://files.pythonhosted.org/packages/db/eb/a147756d720e68dd05a54f3843b430e0111d5860a0e1a751ea109a1668b9/pyoxigraph-0.5.13-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:2dd79c4a50c6e33cd95bd66968a10ead584a89df4943b4a667e7d945f6ddef94", size = 8236915, upload-time = "2026-10-22T19:29:28.528Z" }, + { url = "https://files.pythonhosted.org/packages/65/9a/9d542d5338d10def39d09453b26136899a0320349875b02992a93750d323/pyoxigraph-0.5.13-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aa6e3a1371cfd6038ccbb29147cf5e68b81466b5b1ea9f036ddc586caf9ea03f", size = 7699069, upload-time = "2026-10-22T19:29:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/0e/39/a5477902f7402aea001bfdce20b64c7c49d80428f5f1f171cecc3ffa8442/pyoxigraph-0.5.13-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:687fe80380b77400978e869bb2ce956fcaf44e67a234b3b74c0ac6eea5435997", size = 8231320, upload-time = "2026-10-22T19:29:33.015Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/9985a8e9e7c820928d14fea0bdc738f97256a1641f7f3d2583ede494ed7b/pyoxigraph-0.5.13-cp315-abi3.abi3t-macosx_10_14_x86_64.whl", hash = "sha256:87da45fbd9f0b9748946935061667f66204080ddbaba68c0df2fbcb85d28b769", size = 6376498, upload-time = "2026-10-22T19:29:35.142Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7b/b2fe02d660d12605f70d423357479ee73a718e35a61a0e6e1f07c4138cc4/pyoxigraph-0.5.13-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:f91728b51f92ffc4f22348efe8dfeec50501b711e3cd29cf7d6132f2c8f8b2a4", size = 5867494, upload-time = "2026-10-22T19:29:37.403Z" }, + { url = "https://files.pythonhosted.org/packages/94/8a/cadcc4d050e4614e5ffe30ae996566d9b279c6ae352f838d0ed316bf450d/pyoxigraph-0.5.13-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:0714462f6202590d4fd4dfad71aa8c6487df2c6467e7eddece9202db86865f91", size = 7736579, upload-time = "2026-10-22T19:29:39.355Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/380b937539de69d33fabf54ce5a07c5bc63989aada8ef908ed054f9f2b92/pyoxigraph-0.5.13-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:bcf442352a8a78399f0009f827347a6edc1ba4d230c0a9f88304cdee6ffc3617", size = 8268414, upload-time = "2026-10-22T19:29:41.692Z" }, + { url = "https://files.pythonhosted.org/packages/f5/89/dcf654d3c2a487bf385b56cc2f1ed7fb78301e39d0be8c0269c2ee69b4d2/pyoxigraph-0.5.13-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:5331a391cbbf322debdc8208818c3cb1b68953e528e675299c8fe7d56edc23d4", size = 8927997, upload-time = "2026-10-22T19:29:44.09Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/98dc3f0ef4e52505242601c7e67d5c631506a66ae29a6fe04835e9c6ec85/pyoxigraph-0.5.13-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:a92e41fee2fc7b5da603b620e12de64b03f82f214f56d3a977196cbffbcd4aa0", size = 9492270, upload-time = "2026-10-22T19:29:46.919Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/1699d3ede7c77b41f66b4e36a80f09aa52b3db869d4f16449c014df4c455/pyoxigraph-0.5.13-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:4118c295d41b0cbca5b2ae0966ff34dc1779551c4e07f2e412f566772e826793", size = 5497046, upload-time = "2026-10-22T19:29:49.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/ec/27f5800a6072aeb490837f3bc28380c76e311742982498f2f3fb58ac6f30/pyoxigraph-0.5.13-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:0a5ea71909f186770e1773ef853f2f797eb004a7a2ba1e6ae9fcae97c5967c01", size = 5149490, upload-time = "2026-10-22T19:29:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c3/d93b1379855b64dd104950886974789ddc2758f00aa06ae2565e264b308a/pyoxigraph-0.5.13-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:78dd4c8ba94417daa51ce6bd007d6b49a8f8cc56c2b2a7db4a9cb234b0f48d1e", size = 7704390, upload-time = "2026-10-22T19:29:54.026Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/032c5d36b7bf27e2ea223abeba8121e5e0126f5b0b4cc51ac317ccb3aa2f/pyoxigraph-0.5.13-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:7143fcc779c3dc64bf326d3a3ba8ac802ae2fa14f6b9a557a9605fa5bcc16453", size = 8236874, upload-time = "2026-10-22T19:29:56.252Z" }, + { url = "https://files.pythonhosted.org/packages/d9/48/344910eb6af38946e3b647c8a113bb97d3739b0fbfaf5731ea70cf8a97d0/pyoxigraph-0.5.13-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:a9d9e946bb4ff4a724c71653eb3c768aa216e03a403f0251b8ed182c88a64bcf", size = 7698492, upload-time = "2026-10-22T19:29:59.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/63cb951cec86d7ea217c0d89ef303af95ab2342eeef491837d99ea3003be/pyoxigraph-0.5.13-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:7b66252ff1f3c514d9830974ff85456cfe805450f108f713471170541fa3900f", size = 8231615, upload-time = "2026-10-22T19:30:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/34/7a/6db4183d218768be8962740ab1bec852935c690bd07e0ca1f88ae4599462/pyoxigraph-0.5.13-cp39-abi3-macosx_10_14_x86_64.whl", hash = "sha256:88c0dffa457e30e57e60931bd808c65cfc710f805b1288760518534f23ffbfdd", size = 6344012, upload-time = "2026-10-22T19:30:03.655Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/b8b0fb626ddb4e2d08d3375e2e2c1e1286769f4b64dbdd90680cc4bc7d36/pyoxigraph-0.5.13-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ebf818fc940b44d30f92a4e84afffbac0284eb57567a8a06fe076a482c605132", size = 5843018, upload-time = "2026-10-22T19:30:05.712Z" }, + { url = "https://files.pythonhosted.org/packages/67/da/417fd464225f0a3bc20cf67a8cbd04ad69a06d84b1d99c2e84eaa4b087b1/pyoxigraph-0.5.13-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:560f9842054b69e7688d847dc45bef202548d226e035ab56a15f0187a1f312fb", size = 7706909, upload-time = "2026-10-22T19:30:07.842Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b3/be2ecc6d27e8ac4443f9e90b1d7ba797d4ffc01502b8b65dcc6345ce6ebe/pyoxigraph-0.5.13-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:d12a7de106d1ffac4e3cfca79f498b9dc97704f0e3bf50b5e4d47770d63427b2", size = 8238304, upload-time = "2026-10-22T19:30:10.022Z" }, + { url = "https://files.pythonhosted.org/packages/31/30/652accfd1c983cdafe7ab8945317f884835de2ef3301eba108aa09229fe1/pyoxigraph-0.5.13-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a948cf25ba85580d6ba695d1c911a5c1018dd4f5925cb0a8f2db680c4de53dd6", size = 8892548, upload-time = "2026-10-22T19:30:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/b7ac582c1d2a63b360d744402e40e4325421010b8d0a4eda6992c2602497/pyoxigraph-0.5.13-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:249e5eff4f94b646ec4dcd5c152fdd8db54d2979610790ec00f0c210d2e2b934", size = 9461002, upload-time = "2026-10-22T19:30:14.705Z" }, + { url = "https://files.pythonhosted.org/packages/87/f1/521becfb1cfd4f01603abe4ad8c37fd1d52134bc82bc3a72c4d1c4da3204/pyoxigraph-0.5.13-cp39-abi3-win_amd64.whl", hash = "sha256:5497c901b2a74a76a8ec3aad44ad79d002047b965425570f349b6d08bfdab8ea", size = 5471556, upload-time = "2026-10-22T19:30:17.107Z" }, + { url = "https://files.pythonhosted.org/packages/07/a3/a42eb852e65efbc55a8b87ee5f0537d5712b959d052c4067e24d4c954a4c/pyoxigraph-0.5.13-cp39-abi3-win_arm64.whl", hash = "sha256:0e195f1db8aeba5964b31e40a877da0c42e10fd3ad765c4348f8cf3a1f52fc17", size = 5129516, upload-time = "2026-10-22T19:30:19.089Z" }, + { url = "https://files.pythonhosted.org/packages/db/5b/c941369726a6bf5a12e7d0d9aca2188e0f94aa76b95080f2019c9db4f382/pyoxigraph-0.5.13-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:99a8bc52f76e1474f16e724bf11b4388ce44d255b70480c51bc029170f64d4ac", size = 7711110, upload-time = "2026-10-22T19:30:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/32/e0/39a9985979674b8129de00d4310b9109d864ffb71f81b644a1ad900a220a/pyoxigraph-0.5.13-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9d8d6c67b275ab165d2989ad043a07d7fb1d1b994c4fd0961673a6fd19470f80", size = 8245148, upload-time = "2026-10-22T19:30:23.386Z" }, ] [[package]] From d48b1976da6766df1c220ba38b6086ea14b4071b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:59:52 +0000 Subject: [PATCH 4/5] build(deps): bump pypa/gh-action-pypi-publish in /.github/workflows (#4192) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pypi-publish.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml index 4ec879170b..551c0e0cbd 100644 --- a/.github/workflows/pypi-publish.yaml +++ b/.github/workflows/pypi-publish.yaml @@ -64,6 +64,6 @@ jobs: - name: Publish package 📦 to PyPI if: github.event_name == 'release' - uses: pypa/gh-action-pypi-publish@v1.14.2 + uses: pypa/gh-action-pypi-publish@v1.14.3 with: verbose: true From 4f83a8b785c5f8fe171dc9295f33d36379a1ff92 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 2 Oct 2026 13:23:41 +0200 Subject: [PATCH 5/5] feat(jsonschemagen): emit propertyNames from inlined-dict key slot constraints For an inlined-as-dict slot whose range class has an identifier/key slot, render the key slot's string-applicable constraints onto JSON Schema propertyNames (draft-06+) instead of dropping them. In the inlined-dict form the mapping key is the identifier value, so the key slot's constraints constrain the keys. JSON object keys are always strings, so only pattern, enum (equals_string_in) and a string const (equals_string) are emitted; numeric minimum/maximum, numeric const (equals_number) and allOf are excluded -- a numeric const would otherwise reject every key. structured_pattern is honored when materialize_patterns is enabled, consistent with value patterns. Backward compatible: emitted only when a string-applicable key constraint applies. Signed-off-by: Carlo van Driesten Includes the documentation for the emitted propertyNames constraint. Signed-off-by: jdsika --- docs/generators/json-schema.rst | 66 +++++++ .../src/linkml/generators/jsonschemagen.py | 39 +++++ .../test_generators/test_jsonschemagen.py | 162 ++++++++++++++++++ 3 files changed, 267 insertions(+) diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index 87c85552cb..be72b938bd 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -420,6 +420,72 @@ will generate: LinkML also supports `Structured patterns `_, these are compiled down to patterns during JSON Schema generation. +Dictionary key constraints (propertyNames) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A multivalued, inlined slot whose range class has an identifier slot is +compiled to a JSON object keyed by that identifier (see *Inlining* above). +When the identifier slot carries string-applicable constraints, they are +emitted as a `propertyNames `_ +schema on the container object, so the *keys* of the dictionary are validated, +not just the values: + +.. code-block:: yaml + + slots: + tags: + range: Tag + multivalued: true + inlined: true + uid: + identifier: true + pattern: "^(0|[1-9][0-9]*)$" + +generates on the container: + +.. code-block:: json + + "tags": { + "additionalProperties": {"$ref": "#/$defs/Tag"}, + "propertyNames": {"pattern": "^(0|[1-9][0-9]*)$"}, + "type": "object" + } + +The constraints carried over from the key slot are the ones applicable to JSON +Schema strings, because object keys are always strings (`JSON Schema Core +2019-09, §9.3.2.5 `_): + +* ``pattern`` -- whether written directly on the slot, resolved from a + ``structured_pattern``, or inherited from the slot's ``range`` type (for + example an identifier with ``range: ncname``, or a user-defined type that + declares a ``pattern``); +* ``equals_string_in``, emitted as ``enum``; +* a string ``equals_string``, emitted as ``const``. + +The emitted key pattern is always the same one that applies to the identifier +*inside* the value object, so a key and a redundantly repeated in-object +identifier are now validated identically. + +Numeric constraints -- ``minimum_value``/``maximum_value``, and the numeric +``const`` produced by ``equals_number`` -- are deliberately **not** carried +over: they cannot be satisfied by a string key, and a numeric ``const`` would +reject every key. The ``allOf`` produced by a ``range_expression``, and the +permissible values of an ``enum``-ranged identifier, are likewise out of scope. + +``propertyNames`` composes conjunctively with ``additionalProperties``, so keys +and values are constrained independently. It is emitted only when the key slot +actually carries one of the constraints listed above; an unconstrained key slot +produces exactly the same output as before. + +.. note:: + + Because type-level patterns are included, an identifier slot whose range is + ``ncname`` (or another pattern-bearing type) gains a ``propertyNames`` + entry even if the slot itself declares no constraint. The generated schema + becomes stricter, but only in ways the model already required: data whose + keys satisfy the declared identifier type is unaffected. + + Rules ^^^^^ diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 9cc776f5e3..ef0ea170fb 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -859,6 +859,40 @@ def get_value_constraints_for_slot(self, slot: SlotDefinition | AnonymousSlotExp return constraints + def get_key_constraints_for_slot(self, slot: SlotDefinition | None) -> JsonSchema: + """Constraints applicable to the *keys* of an inlined-as-dict slot. + + In the inlined-dict form the mapping key *is* the value of the range class's + identifier/key slot (https://linkml.io/linkml/schemas/inlining.html) and is not + repeated inside the value object, so constraints declared on that slot -- or + inherited from its type -- are constraints on the object keys. The result is + intended for JSON Schema ``propertyNames``, which composes conjunctively with + ``additionalProperties``. + + JSON object keys are always strings (JSON Schema Core 2019-09, 9.3.2.5), so only + the string-applicable subset of :meth:`get_value_constraints_for_slot` is + returned: ``pattern`` (including a resolved ``structured_pattern`` and a pattern + inherited from the slot's type), a string ``const`` (``equals_string``) and a + string ``enum`` (``equals_string_in``). Numeric constraints -- ``minimum`` and + ``maximum``, and the numeric ``const`` produced by ``equals_number`` -- and the + ``allOf`` produced by ``range_expression`` are excluded: they cannot be satisfied + by a string key, and a numeric ``const`` would reject *every* key. + + :param slot: the identifier or key slot of the range class + :return: a schema for ``propertyNames``; empty when the key is unconstrained + """ + constraints = self.get_value_constraints_for_slot(slot) + + key_constraints = JsonSchema() + for keyword in ("pattern", "const"): + value = constraints.get(keyword) + if isinstance(value, str): + key_constraints[keyword] = value + enum_values = constraints.get("enum") + if isinstance(enum_values, list) and all(isinstance(value, str) for value in enum_values): + key_constraints["enum"] = enum_values + return key_constraints + def _subschema_for_boolean_member( self, parent_slot: SlotDefinition | AnonymousSlotExpression, member: AnonymousSlotExpression ) -> JsonSchema: @@ -935,6 +969,11 @@ def get_subschema_for_slot( else: typ = ["object", "null"] prop = JsonSchema({"type": typ, "additionalProperties": additionalProps}) + # The dict keys are the range's identifier/key values, so that + # slot's string-applicable constraints constrain the keys. + key_constraints = self.get_key_constraints_for_slot(range_id_slot) + if key_constraints: + prop["propertyNames"] = key_constraints self.top_level_schema.add_lax_def(reference, self.aliased_slot_name(range_id_slot)) else: prop = JsonSchema.array_of(JsonSchema.ref_for(reference), include_null, required=slot.required) diff --git a/tests/linkml/test_generators/test_jsonschemagen.py b/tests/linkml/test_generators/test_jsonschemagen.py index 3e83ef242d..6d8e0b32ee 100644 --- a/tests/linkml/test_generators/test_jsonschemagen.py +++ b/tests/linkml/test_generators/test_jsonschemagen.py @@ -2079,3 +2079,165 @@ def test_type_without_base_defaults_to_string(tmp_path): generated = json.loads(JsonSchemaGenerator(str(schema_file)).serialize()) assert generated["$defs"]["MyClass"]["properties"]["value"]["type"] == ["string", "null"] + + +def _inlined_dict_schema( + key_slot_yaml: str, + key_decl: str = "identifier: true", + key_range: str = "string", + extra_yaml: str = "", +) -> str: + """Build a schema with an inlined-as-dict slot whose key slot is configured by + ``key_decl`` (``identifier: true`` or ``key: true``), ``key_range`` (the key slot + range), and ``key_slot_yaml`` (extra YAML lines for the key slot). ``extra_yaml`` is + appended at the top level, for declaring extra ``types``/``enums``.""" + return f""" +id: https://example.org/test-key-constraints +name: test-key-constraints +prefixes: + linkml: https://w3id.org/linkml/ +default_range: string +imports: + - linkml:types +{extra_yaml} +classes: + Container: + tree_root: true + attributes: + entries: + range: Entry + multivalued: true + inlined: true + inlined_as_list: false + Entry: + attributes: + key: + {key_decl} + range: {key_range} +{key_slot_yaml} + val: + range: string +""" + + +@pytest.mark.parametrize("key_decl", ["identifier: true", "key: true"]) +def test_inlined_dict_key_pattern_emits_property_names(key_decl): + """A literal ``pattern`` on the inlined-dict key slot (identifier or key) must be + rendered onto ``propertyNames``.""" + schema = _inlined_dict_schema(' pattern: "^[0-9]+$"', key_decl=key_decl) + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert generated["properties"]["entries"]["propertyNames"] == {"pattern": "^[0-9]+$"} + + +def test_inlined_dict_key_enum_emits_property_names(): + """``equals_string_in`` on the key slot becomes an ``enum`` constraint on keys.""" + schema = _inlined_dict_schema(" equals_string_in:\n - a\n - b") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert generated["properties"]["entries"]["propertyNames"] == {"enum": ["a", "b"]} + + +def test_inlined_dict_no_key_constraint_emits_no_property_names(): + """No constraint on the key slot -> no ``propertyNames`` (unchanged behavior).""" + schema = _inlined_dict_schema("") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert "propertyNames" not in generated["properties"]["entries"] + + +def test_inlined_dict_key_structured_pattern_emits_property_names(): + """``structured_pattern`` on the key slot is resolved and rendered onto + ``propertyNames`` -- identical to how value patterns are handled.""" + schema = _inlined_dict_schema(" structured_pattern:\n syntax: '[0-9]+'") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + + key_pattern = generated["$defs"]["Entry"]["properties"]["key"]["pattern"] + assert generated["properties"]["entries"]["propertyNames"] == {"pattern": key_pattern} + jsonschema.validate({"entries": {"12": {"val": "x"}}}, generated) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"entries": {"bad-key": {"val": "x"}}}, generated) + + +def test_inlined_dict_property_names_rejects_nonmatching_keys(): + """Behavioral check: keys matching the pattern validate; non-matching keys fail.""" + schema = _inlined_dict_schema(' pattern: "^[0-9]+$"') + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + + jsonschema.validate({"entries": {"0": {"val": "x"}}}, generated) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"entries": {"bad-key": {"val": "x"}}}, generated) + + +def test_inlined_dict_key_string_const_emits_property_names(): + """A string ``const`` (``equals_string``) on the key slot becomes a key const.""" + schema = _inlined_dict_schema(" equals_string: fixed") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert generated["properties"]["entries"]["propertyNames"] == {"const": "fixed"} + jsonschema.validate({"entries": {"fixed": {"val": "x"}}}, generated) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"entries": {"other": {"val": "x"}}}, generated) + + +def test_inlined_dict_key_numeric_const_is_not_emitted(): + """A numeric ``const`` (``equals_number``) must NOT be emitted onto propertyNames: + keys are always strings, so a numeric const would reject every key. The keys are + left unconstrained instead.""" + schema = _inlined_dict_schema(" equals_number: 5", key_range="integer") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert "propertyNames" not in generated["properties"]["entries"] + # numeric-looking string keys still validate (unconstrained) + jsonschema.validate({"entries": {"5": {"val": "x"}}}, generated) + jsonschema.validate({"entries": {"anything": {"val": "x"}}}, generated) + + +def test_inlined_dict_key_numeric_bounds_are_not_emitted(): + """Numeric ``minimum``/``maximum`` on the key slot are no-ops on string keys and + must not be emitted (they would be misleading clutter).""" + schema = _inlined_dict_schema(" minimum_value: 1\n maximum_value: 10", key_range="integer") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert "propertyNames" not in generated["properties"]["entries"] + + +@pytest.mark.parametrize( + ("key_range", "extra_yaml"), + [ + ("ncname", ""), + ("DigitString", "types:\n DigitString:\n typeof: string\n pattern: '^[0-9]+$'"), + ], + ids=["base-implied-pattern", "user-defined-type-pattern"], +) +def test_inlined_dict_key_type_pattern_emits_property_names(key_range, extra_yaml): + """A pattern inherited from the key slot's *type* constrains the identifier value just + as a slot-level pattern does, so it must reach ``propertyNames`` too. The emitted key + pattern is exactly the one applied to the identifier inside the value object, so keys + and the (optional) in-object identifier are validated identically.""" + schema = _inlined_dict_schema("", key_range=key_range, extra_yaml=extra_yaml) + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + + value_key_pattern = generated["$defs"]["Entry__identifier_optional"]["properties"]["key"]["pattern"] + assert generated["properties"]["entries"]["propertyNames"] == {"pattern": value_key_pattern} + + +def test_inlined_dict_key_enum_range_emits_no_property_names(): + """A key slot whose range is a LinkML *enum* is compiled to a ``$ref`` on the value + side; ``get_value_constraints_for_slot`` reports no string-applicable constraint for + it, so no ``propertyNames`` is emitted and the keys stay unconstrained.""" + schema = _inlined_dict_schema( + "", key_range="Colour", extra_yaml="enums:\n Colour:\n permissible_values:\n red:\n green:" + ) + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert "propertyNames" not in generated["properties"]["entries"] + + +def test_inlined_dict_key_constraints_helper_drops_non_string_values(): + """``get_key_constraints_for_slot`` keeps only string-applicable keywords, regardless + of which upstream constraint produced them: a numeric ``const`` or a non-string + ``enum`` would reject every key, so both are dropped.""" + schema = _inlined_dict_schema("") + generator = JsonSchemaGenerator(schema) + generator.generate() + + slot = SlotDefinition("key", pattern="^[0-9]+$") + assert generator.get_key_constraints_for_slot(slot) == {"pattern": "^[0-9]+$"} + + assert generator.get_key_constraints_for_slot(SlotDefinition("key", equals_number=5)) == {} + assert generator.get_key_constraints_for_slot(SlotDefinition("key", minimum_value=1)) == {} + assert generator.get_key_constraints_for_slot(None) == {}