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 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/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/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/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/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/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/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/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_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) == {} 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) diff --git a/uv.lock b/uv.lock index 6dd5eb4030..f68c1dc180 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]] @@ -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]] @@ -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]]