diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index cfede5c3b5..4e33f1513b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,16 +4,6 @@ about: Create a report to help us improve title: '' labels: bug assignees: '' - ---- - ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - --- **Describe the bug** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index e0f3f89ae7..270e88779a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,16 +4,6 @@ about: Suggest an idea for this project title: '' labels: feature assignees: '' - ---- - ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: feature -assignees: '' - --- **What is your feature request?** diff --git a/AGENTS.md b/AGENTS.md index 772f7c59ab..c9a178e6c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ All commands use `uv run` prefix (e.g., `uv run pytest`). * Do not "fix" issues by changing or weakening test conditions. Try harder, or ask questions if a test fails. * Avoid try/except blocks, these can mask bugs * Failing fast is a good principle +* Optional dependencies are the documented exception to the two rules above. Guard the import, not the spec: `try: import x` / `except ImportError as exc: raise ImportError("x is required. Install with: pip install 'linkml[extra]'") from exc` - see `generators/bigquerygen.py`. `importlib.util.find_spec` raises under the test fixture below, which hides your message. +* Testing that an optional dependency is absent is not a mock test: use the `mock_missing_import` fixture in `tests/conftest.py`. Do not build isolated environments in CI for this. * Follow the DRY principle * Avoid repeating chunks of code, but also avoid premature over-abstraction * Declarative principles are favored diff --git a/docs/generators/java.rst b/docs/generators/java.rst index fa08565bfd..8c688f5514 100644 --- a/docs/generators/java.rst +++ b/docs/generators/java.rst @@ -135,10 +135,11 @@ since that file is structured to configure every generator at once: java: package: org.example.model -``gen-java`` only ever reads ``generator_args.java.package`` out of this file -- -every other key (``directory``, ``excludes``, other generators' ``generator_args`` -entries, etc.) is ignored, so a full multi-generator project ``config.yaml`` can be -passed as-is without modification. +``gen-java`` reads only the ``generator_args.java`` section of this file (``directory``, +``excludes``, other generators' ``generator_args`` entries, etc. are ignored), so a full +multi-generator project ``config.yaml`` can be passed as-is. Any ``gen-java`` option can +be set there, keyed by its name with dashes as underscores; command-line options take +precedence, and a key that is not an option is reported as a warning and ignored. An explicit ``--package`` command-line option always overrides a value set via ``--config-file``. diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index 2f8f1d8d91..a7bda92f80 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -130,6 +130,19 @@ LinkML supports analogous elements: Use of these elements will be translated into the appropriate JSON-Schema construct. +At the class level, a class carries the expressions of its ancestors and mixins. +A slot condition is unknown for an absent slot unless it decides whether the slot +may be absent, and an instance is invalid only when an expression is definitely +false, as described under "Class-level expressions and absent slots" in +:doc:`Advanced features `. +A slot's `range_expression `_ +constrains each of its values in the same way, with its conditions on the slots +of the value. +A multivalued slot's `has_member `_ becomes +``contains``, and the slot is required, because a slot without a value has no +member. +The SHACL generator reads them the same way. + Inlining ^^^^^^^^ @@ -378,6 +391,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 86d493c66c..3ddc6f7631 100644 --- a/docs/generators/jsonld-context.rst +++ b/docs/generators/jsonld-context.rst @@ -85,6 +85,45 @@ to gen-prefix-map: gen-prefix-map --flatprefixes personinfo.yaml > personinfo.prefixmap.json +URIs as IRIs or as literals +--------------------------- + +By default a ``uri`` or ``uriorcurie`` slot is coerced to ``xsd:anyURI``, a typed +literal. ``--xsd-anyuri-as-iri`` coerces it to ``@id`` instead, so the value becomes an +IRI node, matching the ``sh:nodeKind sh:IRI`` the SHACL generator emits. The OWL +generator accepts the same flag. + +The flag applies to ``uri``, ``uriorcurie`` and their derived types when the +effective datatype remains ``xsd:anyURI``. Datatype IRIs are expanded before +comparison, so full IRIs and alternative prefixes behave identically. A type +derived from ``string`` that declares ``uri: xsd:anyURI`` stays a typed literal with or +without it. Use one for a URI reference that is data rather than a link, such as a file +path that may be relative: as ``@id`` it would be resolved against the document base. + +.. code-block:: yaml + + types: + FilePath: + typeof: string + uri: xsd:anyURI # always "@type": "xsd:anyURI", sh:datatype xsd:anyURI + slots: + homepage: + range: uri # "@id" with --xsd-anyuri-as-iri + file_path: + range: FilePath + +The RDF dumper, SHACL and ShEx generators distinguish the same URI family from +literal-valued types. An explicit datatype override such as ``uri: xsd:string`` +remains literal-valued, including when the type inherits from ``uri``. + +`XSD anyURI `__ admits relative URI +references as literal values. This differs from an RDF IRI node: `JSON-LD type +coercion `__ with ``@id`` interprets +strings as identifiers and resolves relative references against the base IRI. +The existing option selects the JSON-LD/OWL representation of LinkML's URI +family; it is not needed to preserve a deliberately literal-valued type. No +additional option is introduced. + Docs ---- diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 4b6f076fe8..5a85144e6b 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -67,6 +67,150 @@ Mapping .. note:: The current default settings for ``metaclasses`` and ``type-objects`` may change in the future +Prefix normalization +^^^^^^^^^^^^^^^^^^^^ + +Schemas sometimes declare non-standard aliases for well-known namespaces +(e.g. ``sh1:`` for the SHACL namespace, or a versioned alias for ``skos:``). +By default these aliases are carried through into the generated artifact. + +Use ``--normalize-prefixes`` to remap declared prefixes whose namespace IRI +matches a well-known vocabulary to that vocabulary's conventional name in the +output (``owl``, ``rdf``, ``rdfs``, ``skos``, ``sh``, ``xsd``, ...): + +.. code:: bash + + gen-owl --normalize-prefixes schema.yaml + +The mapping is a static, version-independent table; namespace IRIs that are +not in the table are left untouched. The option is also available on +``gen-shacl`` and ``gen-jsonld-context``. + +Declared annotation values +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +An annotation property name does not determine whether its value is a literal +or an RDF node. For example, ``rdfs:Resource`` includes literals, and OWL permits +both IRIs and literals as annotation values. The generator does not maintain a +list of vocabulary properties whose string values should be changed into IRIs. + +Use LinkML's metamodel extension mechanism to declare the distinction. An +``instantiates`` reference identifies a metaclass whose slots describe the +annotations on that schema element. ``nodeidentifier`` denotes an IRI, CURIE or +blank node; ``string`` denotes text, even when the text looks like a URL: + +.. code-block:: yaml + + id: https://example.org/model + name: model + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + owl: http://www.w3.org/2002/07/owl# + imports: [linkml:types] + default_prefix: ex + instantiates: [ex:OntologyMetadata] + annotations: + prior_version: ex:previous + version_label: https://example.org/a-textual-label + classes: + OntologyMetadata: + attributes: + prior_version: + slot_uri: owl:priorVersion + range: nodeidentifier + version_label: + slot_uri: owl:versionInfo + range: string + +The ontology has ``owl:priorVersion `` and +``owl:versionInfo "https://example.org/a-textual-label"``. The same mechanism +works for custom properties and annotations on classes, slots, local types, +enums and non-literal permissible values. Metaclass inheritance and imported +metaclasses are supported. Tags may be slot names, CURIEs or full slot IRIs. + +The SHACL generator uses the same conversion when its existing +``--include-annotations`` option is enabled. Those annotations describe shapes; +they do not add constraints to ordinary data instances. Schema RDF and JSON-LD +serialization retain the LinkML annotation objects and their declarations; +they do not project annotations onto ontology properties as this generator does. + +Declared scalar ranges determine term representation. ``curie`` expands to an +IRI, ``nodeidentifier`` allows IRIs and blank nodes, and XSD datatypes produce +literals. In particular, ``xsd:anyURI`` alone does not mean an IRI node. Use +``nodeidentifier`` when that distinction matters; no new generator flag is +required. Language tags apply to textual literals only. + +This is serialization of declared scalar annotations, not complete metamodel +extension validation. Structured values, non-type ranges and boolean range +expressions currently raise an error rather than guessing a term. Conflicting +metaclass declarations also raise an error. Unresolved external metaclasses and +undeclared tags retain each generator's existing behavior; a declaration must +be available locally or through a schema import to determine the RDF term. +Ordinary string-valued metamodel fields such as ``license`` retain their +metamodel representation. An explicit annotation declaration is required for +an IRI-valued alternative; avoid assigning the same property in both forms +unless both RDF values are intended. + +References: `LinkML metamodel refinement +`__, +`OWL annotation values `__, +`RDF Schema resources `__, and +`DCMI license guidance +`__. + +DCMI agent metadata +^^^^^^^^^^^^^^^^^^^ + +To name an agent by IRI, declare a ``nodeidentifier`` annotation. To record a +name or other textual identifier, declare ``string``. This works for +``dcterms:creator``, ``dcterms:contributor``, ``dcterms:publisher`` and +``dcterms:rightsHolder`` through the same range conversion as custom properties: + +.. code-block:: yaml + + id: https://example.org/model + name: model + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + dcterms: http://purl.org/dc/terms/ + imports: [linkml:types] + default_prefix: ex + instantiates: [ex:AgentMetadata] + annotations: + creator_label: The Example Team + publisher: ex:team + classes: + AgentMetadata: + attributes: + creator_label: + slot_uri: dcterms:creator + range: string + publisher: + slot_uri: dcterms:publisher + range: nodeidentifier + +The ontology header contains ``dcterms:creator "The Example Team"`` and +``dcterms:publisher ``. A string declaration stays +literal even when its value looks like a URL. For SHACL, put the same +``instantiates`` and ``annotations`` on a class or slot to annotate its shape, +with ``--include-annotations`` enabled. + +The current `DCMI definitions +`__ use +``rangeIncludes: Agent`` for these properties, a suggested range rather than +an ``rdfs:range`` restriction. Creator and rights-holder guidance recommends +URIs while permitting literal identifiers. These definitions do not prescribe +a lexical heuristic for converting LinkML annotation strings into nodes. + +The standard ``contributors`` metamodel field already carries URI-or-CURIE +identifiers; an annotation with the same RDF predicate needs its own declaration. +Do not replace a textual creator with a URL just to trigger conversion, and do +not put a person's name in a node-identifier field. Declaring the representation +in the schema avoids another generator flag or a vocabulary-specific list. + + Enums and PermissibleValues ^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -135,6 +279,52 @@ You can control enum and permissible value representation directly in your schem implements: - rdfs:Literal +**Metaclass membership with ``instantiates``** - This LinkML field types a schema +element itself; it does not add slots or type that element's data instances. The OWL +generator maps each declared membership to ``rdf:type`` on the emitted resource. +The mapping applies consistently to schemas, classes, slots, locally emitted types, +enums, and non-literal permissible values. No additional option is required: the +schema already declares the relationship. + +For example, a permissible value represented as a named individual can declare +membership in an external vocabulary class: + +.. code-block:: yaml + + enums: + LinkCategory: + implements: + - owl:NamedIndividual + permissible_values: + isLicense: + meaning: ex:isLicense + instantiates: + - vocab:LicenseCategory + +.. code-block:: turtle + + ex:isLicense a owl:NamedIndividual, ex:LinkCategory, vocab:LicenseCategory . + +For an enum of named individuals, its own class still uses ``owl:oneOf``; this does +not close the external class. A permissible value rendered as an OWL class retains +that representation and also participates as an individual in the membership +assertion (OWL punning). Membership does not become ``rdfs:subClassOf`` and is not +inherited by instances of the emitted class. ``instantiates`` is ignored, with a +warning, on a permissible value rendered as a literal, because RDF literals cannot +be subjects of ``rdf:type`` triples. + +``gen-rdf`` and ``gen-jsonld`` serialize the schema as metamodel data and retain +``linkml:instantiates``. ``gen-owl`` translates that declaration into ontology +membership. Instance validators such as JSON Schema and SHACL must not apply the +schema element's metaclass to ordinary data instances. + +See the `LinkML instantiation guide +`_, +the `instantiates metamodel definition +`_, and the OWL 2 +specifications for `metamodeling `_ +and `mapping class assertions to RDF `_. + **Using URIs vs. text for permissible values:** .. code-block:: yaml @@ -311,6 +501,49 @@ Other examples translation of Biolink schema to OWL +Deterministic output +^^^^^^^^^^^^^^^^^^^^ + +``gen-owl`` output is deterministic by default. The graph is canonicalized with +`RDFC-1.0 `_ before serialization, so repeated +runs over the same schema -- and any two isomorphic graphs -- produce +byte-identical Turtle. No flag is needed, and checked-in artifacts do not churn +between runs. + +RDFC-1.0 numbers blank nodes sequentially (``_:c14n0``, ``_:c14n1``, ...) in +canonical order. That is stable for a fixed graph, but inserting a single +statement can shift the numbering of every blank node ordered after it, so an +unrelated one-line schema edit may rewrite large parts of the file. Pass +``--diff-stable`` to derive labels from blank-node neighbourhoods instead, +reducing label churn across edits. Connected or symmetric structures can still +cause other labels to change; minimal diffs are not guaranteed: + +.. code:: bash + + gen-owl --diff-stable schema.yaml + +Both modes are deterministic and yield isomorphic graphs; only the choice of +label differs. ``--diff-stable`` is off by default because turning it on +relabels the blank nodes in existing output once. + +The same ``--diff-stable/--no-diff-stable`` option is available on ``gen-rdf``, +``gen-shacl`` and ``gen-shex --format rdf``. + +Graphs that are not standard RDF -- literal predicates, as produced by +``gen-shacl`` in annotation mode, or relative IRIs such as the metamodel's +``bibo:status `` -- cannot be canonicalized under RDFC-1.0. Those fall +back to plain rdflib serialization, with blank-node labels canonicalized by +``rdflib.compare.to_canonical_graph``. Those labels are content-derived rather +than run-local, so the fallback remains reproducible across processes. It emits +an ``RDFCanonicalizationWarning``, and ``--diff-stable`` has no effect on that +path -- it warns rather than silently ignoring the request. + +Canonicalization itself is implemented by the +`diffable-rdf `_ library; +``linkml_runtime.utils.rdf_canonicalize.canonicalize_rdf_graph`` is a thin +adapter that re-emits the library's log warnings as Python warnings. + + Docs ---- diff --git a/docs/generators/shacl.rst b/docs/generators/shacl.rst index 3e88f0090f..f17456b41e 100644 --- a/docs/generators/shacl.rst +++ b/docs/generators/shacl.rst @@ -84,6 +84,281 @@ Example Output: shacl:targetClass . +Rule constraints (SHACL-SPARQL) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +LinkML `rules `_ state conditional +constraints ("if slot A holds X, slot B must ..."), which property shapes +cannot express. The generator translates each rule into a +`SHACL-SPARQL constraint `_ +(``sh:sparql``) on the node shape of its class, and of every subclass, since +a rule applies to all members of its class. ``--no-emit-rules`` turns this +off. + +The constraint's query selects each focus node that satisfies the +preconditions and violates the postconditions. ``$this`` is the focus node +(`SHACL §5.3.1 `_), +and each result names the postcondition's property and, where there is one, +the offending value (``sh:resultPath``, ``sh:value``). + +Two patterns are recognised first: + +* **Presence implies value**: precondition ``value_presence: PRESENT`` on a + guard slot, and postcondition ``equals_string`` or ``equals_string_in`` on a + target slot. +* **Exclusive value**: precondition ``has_member: {equals_string: V}``, and + postcondition ``maximum_cardinality`` on the same slot. The older form + with a bare ``equals_string: V`` precondition is read the same way, with a + warning. + +Any other rule with one postcondition slot is composed from the operators +its conditions use: ``value_presence``, ``required``, ``equals_string``, +``equals_string_in``, ``minimum_value``, ``maximum_value``, +``range_expression`` (slot conditions on the slot's class) and +``has_member``. + +All translations follow the JSON Schema generator's ``if`` / ``then``: + +* **Presence:** ``value_presence`` decides, then ``required``. Otherwise a + precondition requires its slot, a postcondition requires its slot unless + the rule is ``open_world``, and a condition inside a ``range_expression`` + or ``has_member`` does not. +* **Multivalued slots:** a condition holds when *every* value satisfies it. + ``has_member`` holds when *some* value does. +* **Values:** ``equals_string`` and ``equals_string_in`` are compared as + strings on enum and ``xsd:string`` slots, where an enum value with a + ``meaning`` is compared as that IRI. On ``xsd:boolean`` slots they are + compared as booleans and must be ``true``, ``false``, ``1`` or ``0``. + ``minimum_value`` and ``maximum_value`` are inclusive bounds, translated + only on slots whose datatype SPARQL compares as a number (``xsd:integer``, + ``decimal``, ``float``, ``double`` and the types derived from them). + +A rule outside these forms is skipped with a warning that names the rule and +the reason. It is never partially translated. ``deactivated`` rules are +ignored and ``bidirectional`` rules are skipped. For a rule with +``elseconditions``, only the if/then direction is emitted, and a warning +says so. + +Example: + +.. code-block:: yaml + + classes: + Document: + attributes: + review_score: + range: integer + status: + range: Status # an enum: draft, approved, published + rules: + - description: A document scoring 4 or more must be approved or published. + preconditions: + slot_conditions: + review_score: + minimum_value: 4 + postconditions: + slot_conditions: + status: + equals_string_in: [approved, published] + +generates (abridged): + +.. code-block:: turtle + + ex:Document a sh:NodeShape ; + sh:sparql [ a sh:SPARQLConstraint ; + sh:message "A document scoring 4 or more must be approved or published." ; + sh:select """SELECT DISTINCT $this ( AS ?path) ?value WHERE { + FILTER EXISTS { $this ?pre0 . } + FILTER NOT EXISTS { $this ?pre0 . FILTER ( !( COALESCE( isNumeric( ?pre0 ) && ?pre0 >= 4, false ) ) ) } + { FILTER NOT EXISTS { $this ?value . } } + UNION { $this ?value . FILTER ( !( COALESCE( ?value = "approved" || ?value = "published", false ) ) ) } + }""" ] . + +A ``Document`` with a ``review_score`` of 4 or more violates the constraint +if it has no ``status``, or once for each ``status`` other than ``approved`` +or ``published``, which the result reports as ``sh:value``. SHACL processors +that support SHACL-SPARQL, such as ``pyshacl``, validate these constraints. + + +Class Expressions +^^^^^^^^^^^^^^^^^ + +Class-level boolean expressions become the SHACL logical constraint components +their metamodel definitions map to (`SHACL §4.6 +`__): + +================== ===================================================== +LinkML SHACL, on the class's ``sh:NodeShape`` +================== ===================================================== +``any_of`` ``sh:or`` over the member shapes +``all_of`` ``sh:and`` over the member shapes +``exactly_one_of`` ``sh:xone`` over the member shapes +``none_of`` one ``sh:not`` per member +================== ===================================================== + +Each member becomes an anonymous node shape. ``is_a`` gives ``sh:class`` for a +class and the value constraint of a type or an enum otherwise, and nested +expressions recurse. Each entry of ``slot_conditions`` gives an +``sh:property`` whose path is that of the slot as induced for the class, so +``slot_usage`` applies: + +* ``required``, ``value_presence`` and the cardinalities give ``sh:minCount`` / + ``sh:maxCount``; +* ``minimum_value`` / ``maximum_value`` give ``sh:minInclusive`` / + ``sh:maxInclusive``, and ``equals_number`` gives both, so that ``5`` also + matches ``5.0``; +* ``pattern`` gives ``sh:pattern``; +* ``equals_string`` and ``equals_string_in`` give ``sh:in``; on an enum slot the + values are the permissible values as the enum renders them, the IRI of their + ``meaning`` where they have one; +* ``range`` gives the same class, type or enum constraint as a slot's range; +* ``range_expression`` gives an ``sh:node``, as described under + `Range Expressions`_; +* ``has_member`` gives an ``sh:qualifiedValueShape``, as described under + `Members`_. + +A shape may have at most one value of ``sh:minInclusive``, ``sh:maxInclusive`` +or ``sh:in``, and of ``sh:pattern``, whose component also takes ``sh:flags`` +(`SHACL §2.1.1 `__). Where one condition +needs one of them twice, for example ``minimum_value`` next to +``equals_number``, the second value goes into an ``sh:and`` member of the +property shape, where it applies to the same values. + +A slot condition is unknown for an absent slot unless it decides whether the slot +may be absent, and an instance violates an expression only when the expression is +definitely false, as described under "Class-level expressions and absent slots" in +:doc:`Advanced features `. +The JSON Schema generator reads them the same way. In SHACL, each expression gets +its "not false" form, and, under ``sh:not``, its "definitely true" form, where +such a condition also requires its slot (``sh:minCount 1``). ``exactly_one_of`` +becomes ``sh:xone`` over the "definitely true" forms of its members. + +.. code-block:: yaml + + GeodeticReferenceSystem: + slots: [code, name] + any_of: + - slot_conditions: + code: + required: true + - slot_conditions: + name: + required: true + +.. code-block:: turtle + + ex:GeodeticReferenceSystem a sh:NodeShape ; + sh:or ( [ sh:property [ sh:path ex:code ; sh:minCount 1 ] ] + [ sh:property [ sh:path ex:name ; sh:minCount 1 ] ] ) ; + ... + +A class expression constrains every instance of its class, so the shape of a +class carries the expressions of its ancestors and mixins as well, as it carries +their slots. Each is translated in the context of that class, where +``slot_usage`` applies. A node typed only with a subclass, as ``rdflib_dumper`` +and the JSON-LD context produce it, is therefore checked without an +``rdfs:subClassOf`` triple in the data. The ``sh:class`` that ``is_a`` gives +recognises instances of subclasses only where the data graph states the +``rdfs:subClassOf`` (`SHACL §4.1.1 +`__), as it does for a +slot's range. + +An operator whose members use anything else is skipped as a whole and logged as +a warning, because leaving out one member would change what the operator +admits. That covers, for example, ``all_members``, ``equals_expression`` or a +slot-level ``any_of`` inside a slot condition, a condition on a name that is not a slot, a condition +on the identifier slot (the node's IRI rather than a property), and +``equals_string`` on a slot whose range does not hold strings. + + +Range Expressions +^^^^^^^^^^^^^^^^^ + +A slot's `range_expression `__ +constrains each of its values as a class-level expression constrains an +instance: its conditions constrain the slots of the value as induced for the +range class, and a value violates it only when it is definitely false. It +becomes an ``sh:node`` (`SHACL §4.7.1 +`__) on the property shape, +next to the range's ``sh:class``, so it constrains only the values of this slot, +and matching it doesn't make a node an instance of the range. The JSON Schema +generator reads it the same way. + +.. code-block:: yaml + + classes: + Document: + slots: [license] + slot_usage: + license: + range_expression: + slot_conditions: + category: + required: true + equals_string: license + Resource: + slots: [category] + slots: + license: + range: Resource + category: + range: string + +.. code-block:: turtle + + ex:Document a sh:NodeShape ; + sh:property [ sh:path ex:license ; + sh:class ex:Resource ; + sh:node [ sh:property [ sh:path ex:category ; + sh:minCount 1 ; + sh:in ( "license" ) ] ] ; + ... ] ; + ... + +A class alternative in a slot's ``any_of`` can carry its own +``range_expression``, and so can a slot condition, where the expression takes +the form of the condition: under ``sh:not``, its "definitely true" form. For +values that aren't instances of a class, an expression can combine types and +enums with ``is_a``, for example ``any_of: [{is_a: OpenLicense}, {is_a: +ProprietaryLicense}]``; such values have no slots for a condition to constrain. +An expression that uses anything SHACL can't express (see above) is skipped and +logged as a warning. + +A value that is a reference is checked against its description in the data +graph, as ``sh:class`` is. The JSON Schema generator can't check it, because +there the value is just an identifier. + + +Members +^^^^^^^ + +A multivalued slot's `has_member `__ asks +for at least one value that satisfies an expression. It becomes an +``sh:qualifiedValueShape`` with ``sh:qualifiedMinCount 1`` (`SHACL §4.7.3 +`__), so +the other values need only satisfy the slot's own constraints. The member +expression constrains one value: its value operators, ``range`` and +``range_expression`` apply. + +.. code-block:: yaml + + slots: + tags: + range: string + multivalued: true + has_member: + equals_string: reviewed + +This accepts ``[reviewed, pending]`` and rejects ``[pending]``. A slot without a +value, absent or empty, has no member, so it fails ``has_member``, as the JSON +Schema generator also reads it. In a slot condition of a class-level expression, +``has_member`` therefore decides whether the slot may be absent, and the member +takes the form of the condition. Several conditions with ``has_member`` may be +satisfied by the same value. A ``has_member`` on a slot that isn't multivalued, +or one that uses anything else, is skipped and logged as a warning. + + Command Line ^^^^^^^^^^^^ diff --git a/docs/howtos/collaborative-development.md b/docs/howtos/collaborative-development.md index 94515df2d1..278e290523 100644 --- a/docs/howtos/collaborative-development.md +++ b/docs/howtos/collaborative-development.md @@ -40,6 +40,38 @@ Some projects may opt to manage their data alongside the schema in the same repo for "registry" style projects, but in other cases the data may be best managed separately in a dedicated store or a repository like Zenodo or Figshare. +(rdf-in-version-control)= +### Keep generated RDF diffs small + +When you track generated RDF in Git, adding a class or property can change many +blank-node labels. The default RDFC-1.0 numbering is repeatable for the same graph, +but an insertion can renumber existing nodes and obscure the actual schema edit. + +The optional `--diff-stable` flag uses +[diffable-rdf](https://github.com/ASCS-eV/diffable-rdf) to reduce this label churn. +The library is installed with LinkML. In a uv project containing your schema, +install LinkML and generate SHACL as follows (using a release that includes this option): + +```bash +uv add linkml +uv run gen-shacl --diff-stable schema.yaml > schema.shacl.ttl +``` + +The flag also works with `gen-owl`, `gen-rdf`, and `gen-shex --format rdf`. +Python callers of `canonicalize_rdf_graph(..., diff_stable=True)` can install +`linkml-runtime`. The `linkml[diff-stable]` and `linkml-runtime[diff-stable]` extras +remain available for compatibility with earlier installation instructions. +The feature is disabled by default; enabling it changes existing labels once, +so keep that regeneration separate from schema edits. + +Labels depend on blank-node neighborhoods. Edits within connected structures or +among symmetric nodes can still affect other labels; minimum diffs are not +guaranteed. Keep tool versions consistent when comparing generated files. +The relabeled output is not the standard RDFC canonical representation. +ShExC, ShEx JSON and instance-data conversion are outside this option's scope. +Non-standard RDF takes the existing fallback path, which warns that it cannot +apply diff-stable labels. + ### Permissively License Your Code and Data (adapted from [O3 guidelines](https://osf.io/vuzt3/)) diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md index c56a808379..2b59901638 100644 --- a/docs/schemas/advanced.md +++ b/docs/schemas/advanced.md @@ -64,6 +64,40 @@ The following LinkML constructs can be used to express boolean constraints: These can be applied at the class or slot level. The range of each of these is an array of *expressions*. +### Class-level expressions and absent slots + +At the class level, each member of `any_of`, `all_of`, `exactly_one_of` and `none_of` is a class expression whose `slot_conditions` constrain the instance's slots. The expressions of a class also constrain the instances of its subclasses, and of the classes that use it as a mixin. + +A slot condition needs a meaning when its slot is absent. The JSON Schema and SHACL generators read it as an SQL `CHECK` constraint reads a condition on a null value: an instance is invalid only when an expression is definitely false. + +- A condition that decides whether its slot may be absent is true or false as usual. Such a condition sets `value_presence: PRESENT` or `ABSENT`, `required: true`, a minimum or exact cardinality of at least 1, `has_member`, or a maximum or exact cardinality of 0. +- Any other condition is *unknown* when its slot is absent. +- `any_of`, `all_of` and `none_of` combine these as "or", "and" and "not". An unknown member doesn't make `any_of` true, nor `none_of` false. +- `exactly_one_of` holds when exactly one member is definitely true. + +```yaml +classes: + Sample: + none_of: + - slot_conditions: + status: + equals_string: retracted +``` + +A `Sample` without `status` is valid: the condition is unknown, so `none_of` isn't false. To require the slot, state it with `status: {required: true}`. + +| Expression | `{}` | `{label: A}` | `{label: B}` | +|---|---|---|---| +| `any_of: [label = A]` | valid | valid | invalid | +| `none_of: [label = A]` | valid | invalid | valid | +| `exactly_one_of: [label = A, note = B]` | invalid | valid | invalid | + +A slot's [range_expression](https://w3id.org/linkml/range_expression) constrains each value of the slot in the same way: its conditions constrain the slots of the value, as induced for the slot's range class. In a slot condition, a `range_expression` is part of the condition, so under `none_of` it must hold definitely. + +A slot's [has_member](https://w3id.org/linkml/has_member) asks for at least one value satisfying its expression. A slot without a value, absent or empty, has no member, so it fails `has_member`. + +[Rules](#rules) are read differently: their preconditions require their slots, and so do their postconditions unless the rule is `open_world`. + ### Unions as ranges [any_of](https://w3id.org/linkml/any_of) can be used to express that a range must satisfy any of a set of ranges. @@ -129,7 +163,7 @@ classes: description: USA and territories must have a specific regex pattern for postal codes and phone numbers ``` -See above for implementation status +The [JSON Schema generator](../generators/json-schema) translates rules into `if` / `then` subschemas, and the [SHACL generator](../generators/shacl) into SHACL-SPARQL constraints. ## Defining slots diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index 01c524e634..30012d73ae 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -50,7 +50,10 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "openpyxl", "parse", "prefixcommons >= 0.1.7", - "prefixmaps >= 0.2.2", + # TODO(prefixmaps-0.2.8): Replace git pin with "prefixmaps >= 0.2.8" once released, + # then remove [tool.hatch.metadata] allow-direct-references and regenerate uv.lock. + # Tracked in: https://github.com/linkml/prefixmaps/issues/82 + "prefixmaps @ git+https://github.com/linkml/prefixmaps@75435150a1b31760b9780af2b64a265943a9b263", "pydantic>=2.13.5,<3.0.0", "pyjsg >= 0.12.3", "pyshex >= 0.9.0", @@ -68,6 +71,9 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "platformdirs>=4.12.1", ] +[project.optional-dependencies] +diff-stable = ["linkml-runtime[diff-stable]>=1.10.0,<2.0.0"] + [dependency-groups] lint = [ "black >= 24.0.0", @@ -79,6 +85,7 @@ shacl = [ "pyshacl>=0.40.1", ] tests = [ + "linkml-runtime[diff-stable]", { include-group = "lint" }, { include-group = "typing" }, { include-group = "shacl" }, @@ -207,6 +214,10 @@ vcs = "git" style = "pep440" fallback-version = "0.0.0" +[tool.hatch.metadata] +# TODO(prefixmaps-0.2.8): Remove this section once the git pin is replaced with >= 0.2.8 +allow-direct-references = true + [tool.hatch.version] source = "uv-dynamic-versioning" diff --git a/packages/linkml/src/linkml/generators/common/annotations.py b/packages/linkml/src/linkml/generators/common/annotations.py new file mode 100644 index 0000000000..7da1078667 --- /dev/null +++ b/packages/linkml/src/linkml/generators/common/annotations.py @@ -0,0 +1,78 @@ +"""RDF annotation terms governed by declared LinkML metamodel extensions.""" + +from typing import Any + +from rdflib import XSD, BNode, Literal, URIRef + +from linkml_runtime.linkml_model import Element, PermissibleValue, SlotDefinition +from linkml_runtime.linkml_model.types import SHEX +from linkml_runtime.utils.schemaview import SchemaView +from linkml_runtime.utils.uri_validator import validate_uri + + +def declared_annotation( + schema: SchemaView, + element: Element | PermissibleValue, + tag: str, + value: Any, + language: str | None = None, +) -> tuple[URIRef, URIRef | BNode | Literal] | None: + """Serialize a scalar annotation using an instantiated metaclass's slot. + + Match local slot names or expanded slot URIs in the induced metaclass, + including inherited and imported definitions. Unresolved external metaclasses + and undeclared tags leave the caller's existing behavior unchanged. Conflicting + declarations fail instead of selecting a range according to iteration order. + + This is RDF serialization, not annotation constraint validation. The + metamodel-extension contract is described in LinkML specification section 5, + ``Metamodel refinement using annotations``. + """ + declarations: list[SlotDefinition] = [] + for metaclass in element.instantiates: + identifier = schema.expand_curie(metaclass) + for cls in schema.all_classes().values(): + if schema.get_uri(cls, expand=True) != identifier: + continue + for slot in schema.class_induced_slots(cls.name): + if tag == slot.name or schema.expand_curie(tag) == schema.get_uri(slot, expand=True): + declarations.append(slot) + if not declarations: + return None + terms = { + (URIRef(schema.get_uri(slot, expand=True)), _annotation_value(schema, slot, value, language)) + for slot in declarations + } + if len(terms) != 1: + raise ValueError(f"Conflicting metaclass declarations for annotation {tag!r}") + return terms.pop() + + +def _annotation_value( + schema: SchemaView, slot: SlotDefinition, value: Any, language: str | None +) -> URIRef | BNode | Literal: + """Convert a declared scalar range to its RDF term without vocabulary guessing.""" + if slot.any_of or slot.all_of or slot.exactly_one_of or slot.none_of or slot.range_expression: + raise ValueError(f"Annotation {slot.name!r} requires a single scalar range for RDF serialization") + if not isinstance(value, str | bool | int | float): + raise ValueError(f"Annotation {slot.name!r} requires a scalar RDF value") + if slot.range not in schema.all_types(): + raise ValueError(f"Annotation {slot.name!r} has unsupported non-scalar range {slot.range!r}") + typ = schema.induced_type(slot.range) + datatype = URIRef(schema.expand_curie(typ.uri)) if typ.uri else None + # The standard curie type requires expansion in RDF even though its lexical + # datatype is xsd:string. nodeidentifier explicitly denotes a non-literal; + # xsd:anyURI alone does not distinguish an IRI node from a URI literal. + curie = datatype == XSD.string and "curie" in schema.type_ancestors(slot.range) + if datatype in (SHEX.iri, SHEX.nonLiteral) or curie: + if not isinstance(value, str): + raise ValueError(f"Annotation {slot.name!r} requires a node identifier") + if datatype == SHEX.nonLiteral and value.startswith("_:") and len(value) > 2: + return BNode(value[2:]) + expanded = schema.expand_curie(value) + if not validate_uri(expanded): + raise ValueError(f"Annotation {slot.name!r} requires an absolute IRI or declared CURIE: {value!r}") + return URIRef(expanded) + if datatype in (None, XSD.string): + return Literal(value, lang=language if isinstance(value, str) else None) + return Literal(value, datatype=datatype) diff --git a/packages/linkml/src/linkml/generators/common/class_expression.py b/packages/linkml/src/linkml/generators/common/class_expression.py new file mode 100644 index 0000000000..e5fadb5f39 --- /dev/null +++ b/packages/linkml/src/linkml/generators/common/class_expression.py @@ -0,0 +1,106 @@ +"""Shared semantics of slot conditions in class-level boolean expressions. + +The class-level operators ``any_of``, ``all_of``, ``exactly_one_of`` and +``none_of`` combine class expressions whose slot conditions constrain slots of +an instance. The specification does not say what a condition means for an +absent slot. Generators that translate class expressions read it the same +way, as an SQL CHECK constraint (ISO/IEC 9075) reads a condition on a null +value: a condition that doesn't state presence is *unknown* for an absent +slot, and an expression is violated only when it is definitely false. + +Each expression therefore has two forms: + +* its **"not false" form**, which the instance must satisfy, and in which a + condition holds for an absent slot unless it states presence; +* its **"definitely true" form**, used under a negation, in which a condition + that doesn't state presence also requires its slot. + +``none_of`` takes its members in the opposite form, ``exactly_one_of`` counts +the members that are definitely true, and ``any_of`` / ``all_of`` keep the +form. + +>>> from linkml_runtime.linkml_model.meta import SlotDefinition +>>> value_bounds(SlotDefinition("label", equals_string="A"), definite=False) +(0, None) +>>> value_bounds(SlotDefinition("label", equals_string="A"), definite=True) +(1, None) +>>> value_bounds(SlotDefinition("label", required=True, value_presence="ABSENT"), definite=True) +(0, 0) +>>> value_bounds(SlotDefinition("tags", minimum_cardinality=2, maximum_cardinality=3), definite=False) +(2, 3) +""" + +from linkml_runtime.linkml_model.meta import PresenceEnum, SlotDefinition + +_PRESENT = PresenceEnum(PresenceEnum.PRESENT) +_ABSENT = PresenceEnum(PresenceEnum.ABSENT) + + +def states_presence(condition: SlotDefinition) -> bool: + """Whether *condition* decides whether its slot may be absent. + + Such a condition is definitely true or false for an absent slot: + ``value_presence: PRESENT`` or ``ABSENT``; ``required: true``, unless + ``value_presence`` overrides it; a minimum or exact cardinality of at least + 1, and ``has_member``, which asks for at least one value, all of which an + absent slot fails; and a maximum or exact cardinality of 0, which it + satisfies. Other bounds, ``required: false`` and ``UNCOMMITTED`` leave + absence open, so adding one never changes the verdict on an absent slot. + + >>> states_presence(SlotDefinition("label", equals_string="A")) + False + >>> states_presence(SlotDefinition("label", required=False, equals_string="A")) + False + >>> states_presence(SlotDefinition("tags", maximum_cardinality=5)) + False + >>> states_presence(SlotDefinition("tags", maximum_cardinality=0)) + True + >>> states_presence(SlotDefinition("tags", minimum_cardinality=1)) + True + >>> states_presence(SlotDefinition("tags", has_member={"equals_string": "A"})) + True + """ + if condition.value_presence is not None: + if condition.value_presence in (_PRESENT, _ABSENT): + return True + elif condition.required: + return True + if condition.has_member is not None: + return True + lower = (condition.minimum_cardinality, condition.exact_cardinality) + upper = (condition.maximum_cardinality, condition.exact_cardinality) + return any(bound is not None and bound >= 1 for bound in lower) or 0 in upper + + +def value_bounds(condition: SlotDefinition, definite: bool) -> tuple[int, int | None]: + """The least and the greatest number of values *condition* allows its slot. + + ``value_presence`` takes precedence over ``required``, and ``has_member`` + asks for at least one value. In the "definitely true" form (*definite*), a + condition that doesn't state presence also requires the slot. The greatest + number is ``None`` when unbounded. + + >>> value_bounds(SlotDefinition("tags", has_member={"equals_string": "A"}), definite=False) + (1, None) + """ + lower, upper = [0], [] + if condition.value_presence is not None: + if condition.value_presence == _PRESENT: + lower.append(1) + elif condition.value_presence == _ABSENT: + upper.append(0) + elif condition.required: + lower.append(1) + if condition.has_member is not None: + lower.append(1) + if definite and not states_presence(condition): + lower.append(1) + for bound, target in ( + (condition.minimum_cardinality, lower), + (condition.exact_cardinality, lower), + (condition.maximum_cardinality, upper), + (condition.exact_cardinality, upper), + ): + if bound is not None: + target.append(int(bound)) + return max(lower), min(upper) if upper else None diff --git a/packages/linkml/src/linkml/generators/common/subproperty.py b/packages/linkml/src/linkml/generators/common/subproperty.py index 9b136e2429..d78c8e3f09 100644 --- a/packages/linkml/src/linkml/generators/common/subproperty.py +++ b/packages/linkml/src/linkml/generators/common/subproperty.py @@ -7,6 +7,8 @@ - Collecting and deduplicating slot hierarchy values """ +from rdflib.namespace import XSD + from linkml_runtime.linkml_model.meta import SlotDefinition from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.schemaview import SchemaView @@ -15,9 +17,27 @@ 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_xsd_anyuri_range(sv: SchemaView, range_type: str | None) -> bool: + """Whether a URI-family type's effective datatype is ``xsd:anyURI``. + + LinkML's standard ``uri`` and ``uriorcurie`` types and their descendants + participate in the existing literal-to-IRI option. String-derived types do + not. Resolve the datatype before comparing so prefix aliases and explicit + datatype overrides have the same meaning in every generator. + + The roots are defined at https://w3id.org/linkml/types. The runtime dumper + implements the same small rule independently because the two packages may + be released separately; real serialization tests check their agreement. + """ + if range_type not in sv.all_types(): + return False + typ = sv.induced_type(range_type) + return ( + bool(typ.uri) + and sv.expand_curie(typ.uri) == str(XSD.anyURI) + and any(ancestor in ("uri", "uriorcurie") for ancestor in sv.type_ancestors(range_type)) + ) def is_uri_range(sv: SchemaView, range_type: str | None) -> bool: @@ -67,35 +87,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/golanggen/golanggen.py b/packages/linkml/src/linkml/generators/golanggen/golanggen.py index 351b5e89c6..61c6383ba3 100644 --- a/packages/linkml/src/linkml/generators/golanggen/golanggen.py +++ b/packages/linkml/src/linkml/generators/golanggen/golanggen.py @@ -28,7 +28,7 @@ ) from linkml.generators.oocodegen import PACKAGE, OOCodeGenerator from linkml.utils.deprecation import deprecated_fields, deprecation_warning -from linkml.utils.generator import read_generator_config, shared_arguments +from linkml.utils.generator import apply_config_defaults, read_generator_config, shared_arguments from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition from linkml_runtime.utils.formatutils import camelcase, underscore from linkml_runtime.utils.schemaview import SchemaView @@ -168,6 +168,7 @@ class GolangGenerator(OOCodeGenerator): generatorversion = "0.2.0" valid_formats = ["go", "golang"] file_extension = "go" + config_section_name = "golang" # ObjectVars package: PACKAGE | None = None @@ -712,9 +713,10 @@ def _template_environment(self) -> Environment: "--config-file", "-C", type=click.File("rb"), - help="Path to a gen-project-style YAML config file setting " - "'generator_args: {golang: {package: ...}}'. An explicit --package always " - "takes precedence over the config file.", + help="Path to a YAML config file supplying defaults under " + "'generator_args: {golang: {package: ...}}'. Keys are this command's own option " + "names with dashes as underscores; explicit command-line options always take " + "precedence over the config file.", ) @click.option( "--alphabetical-sort/--no-alphabetical-sort", @@ -753,10 +755,10 @@ def _template_environment(self) -> Environment: ) @click.version_option(__version__, "-V", "--version") @click.command(name="golang") +@click.pass_context def cli( + ctx, yamlfile, - package: str | None = None, - package_name: str | None = None, config_file=None, alphabetical_sort: bool = False, nullable_primitives: bool = True, @@ -773,13 +775,27 @@ def cli( - JSON tags for serialization - Struct embedding for inheritance """ + # --package / --package-name consumed from **args; fold the deprecated alias before overlay + package_name = args.pop("package_name", None) + config = read_generator_config(config_file, GolangGenerator.config_section_name) if package_name is not None: deprecation_warning("golanggen-package-name-option") - if package is None: - package = package_name - if package is None: - package = read_generator_config(config_file, "golang").get("package") - GolangGenerator.validate_generator_args({"package": package}) + if args.get("package") is None: + args["package"] = package_name + # a deprecated --package-name outranks a config-file package + config = {k: v for k, v in config.items() if k != "package"} + apply_config_defaults(ctx, config, args) + # a config-file-supplied deprecated `package_name` folds same as --package-name + if args.get("package_name") is not None and args.get("package") is None: + deprecation_warning("golanggen-package-name-option") + args["package"] = args.pop("package_name") + args.pop("package_name", None) + GolangGenerator.validate_generator_args(args) + # rebind named locals so overlaid config values are honored and never collide with **args below + alphabetical_sort = args.pop("alphabetical_sort", alphabetical_sort) + nullable_primitives = args.pop("nullable_primitives", nullable_primitives) + named_slot_types = args.pop("named_slot_types", named_slot_types) + template_dir = args.pop("template_dir", template_dir) if template_dir is not None: if not Path(template_dir).exists(): @@ -791,7 +807,6 @@ def cli( # its own traceback rather than being caught and mistaken for one. gen = GolangGenerator( yamlfile, - package=package, alphabetical_sort=alphabetical_sort, nullable_primitives=nullable_primitives, named_slot_types=named_slot_types, diff --git a/packages/linkml/src/linkml/generators/javagen.py b/packages/linkml/src/linkml/generators/javagen.py index e7620691d7..3b8bb12ec9 100644 --- a/packages/linkml/src/linkml/generators/javagen.py +++ b/packages/linkml/src/linkml/generators/javagen.py @@ -11,7 +11,7 @@ from linkml._version import __version__ from linkml.generators.oocodegen import OOCodeGenerator, OODocument from linkml.utils.deprecation import deprecated_fields, deprecation_warning -from linkml.utils.generator import read_generator_config, shared_arguments +from linkml.utils.generator import apply_config_defaults, read_generator_config, shared_arguments from linkml_runtime import SchemaView from linkml_runtime.linkml_model.meta import ClassDefinition, SlotDefinition, TypeDefinition from linkml_runtime.utils.formatutils import camelcase @@ -284,6 +284,7 @@ class JavaGenerator(OOCodeGenerator): generatorversion = "0.0.1" valid_formats = ["java"] file_extension = "java" + config_section_name = "java" # ObjectVars template_file: str | None = None @@ -600,8 +601,9 @@ def needs_extra_slots(self, klass: ClassDefinition) -> bool: "--config-file", "-C", type=click.File("rb"), - help="Path to a gen-project-style YAML config file setting " - "'generator_args: {java: {package: ...}}'. An explicit --package always takes " + help="Path to a YAML config file supplying defaults under " + "'generator_args: {java: {package: ...}}'. Keys are this command's own option " + "names with dashes as underscores; explicit command-line options always take " "precedence over the config file.", ) @click.option( @@ -612,6 +614,7 @@ def needs_extra_slots(self, klass: ClassDefinition) -> bool: @click.option("--template-variant", help="Use the specified template variant") @click.option( "--template-file", + type=click.Path(exists=True, dir_okay=False), help="""Optional jinja2 template to use for class generation (takes precedence over --template-dir)""", ) @@ -627,10 +630,11 @@ def needs_extra_slots(self, klass: ClassDefinition) -> bool: @click.option("--use-aliases/--no-use-aliases", default=False, help="Use aliases when available to name fields") @click.version_option(__version__, "-V", "--version") @click.command(name="java") +@click.pass_context def cli( + ctx, yamlfile, output_directory=None, - package=None, config_file=None, template_dir=None, template_variant=None, @@ -646,9 +650,20 @@ def cli( **args, ): """Generate java classes to represent a LinkML model""" - if package is None: - package = read_generator_config(config_file, "java").get("package") - JavaGenerator.validate_generator_args({"package": package}) + # --package now consumed from **args so config overlay can fill, and it reaches the generator + config = read_generator_config(config_file, JavaGenerator.config_section_name) + apply_config_defaults(ctx, config, args) + JavaGenerator.validate_generator_args(args) + # rebind named locals so overlaid config values are honored and never collide with **args below + output_directory = args.pop("output_directory", output_directory) + template_dir = args.pop("template_dir", template_dir) + template_variant = args.pop("template_variant", template_variant) + template_file = args.pop("template_file", template_file) + generate_records = args.pop("generate_records", generate_records) + true_enums = args.pop("true_enums", true_enums) + use_aliases = args.pop("use_aliases", use_aliases) + extra_template = args.pop("extra_template", extra_template) + visitor = args.pop("visitor", visitor) if generate_records: template_variant = "records" if template_file is not None: @@ -667,6 +682,7 @@ def cli( args["metadata"] = head if yamlfile.is_dir(): + args.pop("package", None) # inferred per schema from the directory layout # Generate code for all root schemas under the specified directory, # inferring the package name from the directory hierarchy schemas: list[tuple[Path, Path, str]] = [] @@ -700,7 +716,6 @@ def cli( # its own traceback rather than being caught and mistaken for one. generator = JavaGenerator( yamlfile, - package=package, template_dir=template_dir, template_file=template_file, true_enums=true_enums, diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py index 7f83b6dddf..f6ea7072d7 100644 --- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py +++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py @@ -5,6 +5,7 @@ import json import os import re +from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -14,19 +15,16 @@ from rdflib import SKOS, XSD, Namespace from linkml._version import __version__ +from linkml.generators.common.subproperty import is_xsd_anyuri_range from linkml.utils.deprecation import deprecated_fields -from linkml.utils.generator import Generator, shared_arguments -from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition +from linkml.utils.generator import Generator, shared_arguments, well_known_prefix_map +from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, Prefix, SlotDefinition from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import camelcase, underscore 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", @@ -83,8 +81,11 @@ class ContextGenerator(Generator): """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. + ``sh:nodeKind sh:IRI`` for the same types. Types derived from ``uri`` or + ``uriorcurie`` follow them; a type derived from ``string`` that declares + ``uri: xsd:anyURI`` stays a typed literal, as in the OWL generator. """ + _type_view_cache: SchemaView | None = field(default=None, repr=False) # Framing (opt-in via CLI flag) emit_frame: bool = False @@ -93,6 +94,9 @@ class ContextGenerator(Generator): frame_root: str | None = None def __post_init__(self) -> None: + # Must be set before super().__post_init__() because the parent triggers + # the visitor pattern (visit_schema), which accesses _prefix_remap. + self._prefix_remap: dict[str, str] = {} super().__post_init__() if self.namespaces is None: raise TypeError("Schema text must be supplied to context generator. Preparsed schema will not work") @@ -130,8 +134,14 @@ def _collect_external_elements(sv: SchemaView) -> tuple[set[str], set[str]]: external_slots.update(schema_def.slots.keys()) return external_classes, external_slots + def add_prefix(self, ncname: str) -> None: + """Add a prefix, applying well-known prefix normalisation when enabled.""" + super().add_prefix(self._prefix_remap.get(ncname, ncname)) + def visit_schema(self, base: str | Namespace | None = None, output: str | None = None, **_): - # Add any explicitly declared prefixes + # Add any explicitly declared prefixes. + # Direct .add() is safe here: the normalisation block below explicitly + # rewrites emit_prefixes entries for any renamed prefixes (Cases 1-3). for prefix in self.schema.prefixes.values(): self.emit_prefixes.add(prefix.prefix_prefix) @@ -139,6 +149,68 @@ def visit_schema(self, base: str | Namespace | None = None, output: str | None = for pfx in self.schema.emit_prefixes: self.add_prefix(pfx) + # Normalise well-known prefix names when --normalize-prefixes is set. + # If the schema declares a non-standard alias for a namespace that has + # a well-known standard name (e.g. ``sdo`` for + # ``https://schema.org/``), replace the alias with the standard name + # so that generated JSON-LD contexts use the conventional prefix. + # + # Three cases are handled: + # 1. Standard prefix is not yet bound → just rebind from old to new. + # 2. Standard prefix is bound to a *different* URI: + # a. User-declared (in schema.prefixes) → collision, skip with warning. + # b. Runtime default (e.g. linkml-runtime's ``schema: http://…``) + # → remove stale binding, then rebind. + # 3. Standard prefix is already bound to the *same* URI (duplicate) + # → just drop the non-standard alias. + # + # A remap dict is stored for ``_build_element_id`` because + # ``prefix_suffix()`` splits CURIEs on ``:`` without looking up the + # namespace dict. + self._prefix_remap.clear() + if self.normalize_prefixes: + wk = well_known_prefix_map() + for old_pfx in list(self.namespaces): + url = str(self.namespaces[old_pfx]) + std_pfx = wk.get(url) + if not std_pfx or std_pfx == old_pfx: + continue + if std_pfx in self.namespaces: + if str(self.namespaces[std_pfx]) != url: + # Case 2: std_pfx is bound to a different URI. + # If the user explicitly declared std_pfx in the schema, + # it is intentional — skip to avoid data loss. + if std_pfx in self.schema.prefixes: + self.logger.warning( + "Prefix collision: cannot rename '%s' to '%s' because '%s' is " + "already declared for <%s>; skipping normalisation for <%s>", + old_pfx, + std_pfx, + std_pfx, + str(self.namespaces[std_pfx]), + url, + ) + continue + # Not user-declared (e.g. linkml-runtime default) — safe to remove + self.emit_prefixes.discard(std_pfx) + del self.namespaces[std_pfx] + else: + # Case 3: standard prefix already bound to same URI + # — just drop the non-standard alias + del self.namespaces[old_pfx] + if old_pfx in self.emit_prefixes: + self.emit_prefixes.discard(old_pfx) + self.emit_prefixes.add(std_pfx) + self._prefix_remap[old_pfx] = std_pfx + continue + # Case 1 (or Case 2 after stale removal): bind standard name + self.namespaces[std_pfx] = self.namespaces[old_pfx] + del self.namespaces[old_pfx] + if old_pfx in self.emit_prefixes: + self.emit_prefixes.discard(old_pfx) + self.emit_prefixes.add(std_pfx) + self._prefix_remap[old_pfx] = std_pfx + # Add the default prefix if self.schema.default_prefix: dflt = self.namespaces.prefix_for(self.schema.default_prefix) @@ -146,6 +218,8 @@ def visit_schema(self, base: str | Namespace | None = None, output: str | None = self.default_ns = dflt if self.default_ns: default_uri = self.namespaces[self.default_ns] + # Direct .add() is safe: default_ns is already resolved from + # the (possibly normalised) namespace bindings above. self.emit_prefixes.add(self.default_ns) else: default_uri = self.schema.default_prefix @@ -297,7 +371,6 @@ 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 @@ -306,7 +379,7 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N range_uri = self.namespaces.uri_for(range_type.uri) if range_uri == XSD.string: coercions.add(None) - elif range_uri in uri_ranges: + elif self._coerces_to_id(range_name): coercions.add("@id") else: coercions.add(range_type.uri) @@ -317,6 +390,35 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N return True, next(iter(coercions)) return False, None + def _coerces_to_id(self, range_name: str) -> bool: + """Whether values of the LinkML type *range_name* are IRIs (``@type: @id``) rather than literals. + + The ShEx node types always are. ``xsd:anyURI`` is, with ``--xsd-anyuri-as-iri``, only + for ``uri``, ``uriorcurie`` and types derived from them, the same rule the OWL + generator applies (:func:`is_xsd_anyuri_range`). A type derived from ``string`` that + declares ``uri: xsd:anyURI`` is a typed literal: a URI reference kept as a string, which + must not be resolved against the document base. + """ + range_uri = self.namespaces.uri_for(self.schema.types[range_name].uri) + if range_uri in URI_RANGES: + return True + return self.xsd_anyuri_as_iri and range_uri == XSD.anyURI and is_xsd_anyuri_range(self._type_view(), range_name) + + def _type_view(self) -> SchemaView: + """A SchemaView of the schema, for type ancestry; built once when the generator has none.""" + if self.schemaview is not None: + return self.schemaview + if self._type_view_cache is None: + # SchemaLoader already resolved the imports; source_file is only + # provenance and may no longer name an available file. + type_schema = deepcopy(self.schema) + type_schema.imports = [] + for prefix, namespace in self.namespaces.items(): + if not prefix.startswith("@"): + type_schema.prefixes[prefix] = Prefix(prefix, str(namespace)) + self._type_view_cache = SchemaView(type_schema) + return self._type_view_cache + def _vocab_eligible_enum(self, enum: EnumDefinition) -> tuple[bool, str | None]: """Check if an enum qualifies for ``@type: @vocab`` context generation. @@ -412,10 +514,9 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: 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: + elif self._coerces_to_id(slot.range): slot_def["@type"] = "@id" else: slot_def["@type"] = range_type.uri @@ -429,7 +530,12 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: self._build_element_id(slot_def, slot.slot_uri) self.add_mappings(slot) if slot_def: - if self.use_curies: + if slot.identifier: + # Identifier slots map to the JSON-LD @id keyword. The context key + # must be the slot *name* (the term used in instance data), not a + # CURIE — even when --use-curies is enabled. + key = underscore(aliased_slot_name) + elif self.use_curies: key = self._curie(slot) else: key = underscore(aliased_slot_name) @@ -480,7 +586,7 @@ def _add_if_type_differs(slot_name: str, override_range: str) -> None: self._build_element_id(entry, global_slot.slot_uri) if override_type is not None: entry["@type"] = override_type - if self.use_curies: + if self.use_curies and not global_slot.identifier: scoped[self._curie(global_slot)] = entry else: scoped[underscore(slot_name)] = entry @@ -509,6 +615,11 @@ def _build_element_id(self, definition: Any, uri: str) -> None: @return: None """ uri_prefix, uri_suffix = self.namespaces.prefix_suffix(uri) + # Apply well-known prefix normalisation (e.g. sdo → schema). + # prefix_suffix() splits CURIEs on ':' without checking the + # namespace dict, so it may return a stale alias. + if uri_prefix and uri_prefix in self._prefix_remap: + uri_prefix = self._prefix_remap[uri_prefix] is_default_namespace = uri_prefix == self.context_body["@vocab"] or uri_prefix == self.namespaces.prefix_for( self.context_body["@vocab"] ) diff --git a/packages/linkml/src/linkml/generators/jsonldgen.py b/packages/linkml/src/linkml/generators/jsonldgen.py index 91759a3653..3b362e0cc3 100644 --- a/packages/linkml/src/linkml/generators/jsonldgen.py +++ b/packages/linkml/src/linkml/generators/jsonldgen.py @@ -58,6 +58,9 @@ class JSONLDGenerator(Generator): original_schema: SchemaDefinition = None """See https://github.com/linkml/linkml/issues/871""" + original_base_dir: str | None = None + """The caller's base_dir, before SchemaLoader replaces it with one derived from the schema path""" + context: Sequence[str] | None = field(default_factory=list) """Path to a JSONLD context file""" @@ -66,6 +69,7 @@ class JSONLDGenerator(Generator): def __post_init__(self) -> None: self.original_schema = deepcopy(self.schema) + self.original_base_dir = self.base_dir super().__post_init__() def _add_type(self, node: YAMLRoot) -> dict: @@ -141,8 +145,7 @@ def adjust_slot(self, slot: SlotDefinition) -> None: def visit_class(self, cls: ClassDefinition) -> bool: self._visit(cls) - if hasattr(cls, "class_uri"): - delattr(cls, "class_uri") + cls.class_uri = self.namespaces.uri_for(cls.class_uri) # Slot usage is a construction artifact # TODO: Figure out why this is here. It isn't good form to alter a schema that may be used by other things cls.slot_usage = {} @@ -187,9 +190,14 @@ def end_schema( context_kwargs["metadata"] = False # Forward importmap/base_dir so the spawned ContextGenerator can # re-resolve any URI-style imports in ``self.original_schema`` - # through the same ``--importmap`` the caller supplied. + # through the same ``--importmap`` the caller supplied. Forward the + # caller's base_dir, not the one SchemaLoader derived from the schema + # path: the latter would resolve a relative path against its own + # directory twice. context_kwargs.setdefault("importmap", self.importmap) - context_kwargs.setdefault("base_dir", self.base_dir) + context_kwargs.setdefault("base_dir", self.original_base_dir) + # Forward prefix normalisation into the inline @context. + context_kwargs.setdefault("normalize_prefixes", self.normalize_prefixes) add_prefixes = ContextGenerator(self.original_schema, **context_kwargs).serialize() add_prefixes_json = loads(add_prefixes) metamodel_ctx = self.metamodel_context or METAMODEL_CONTEXT_URI diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 586b21dc4b..fd5cfc236d 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -2,7 +2,7 @@ import json import logging import os -from copy import deepcopy +from copy import copy, deepcopy from dataclasses import dataclass, field from typing import Any, cast @@ -14,6 +14,7 @@ from linkml.generators.common import build from linkml.generators.common.array import ArrayRangeGenerator, ArrayRepresentation from linkml.generators.common.build import RangeResult +from linkml.generators.common.class_expression import value_bounds from linkml.generators.common.lifecycle import LifecycleMixin from linkml.generators.common.subproperty import get_subproperty_values from linkml.generators.common.type_designators import ( @@ -275,24 +276,12 @@ def add_property( self["required"].append(canonical_name) # JSON Schema does not have a very natural way to express that a property cannot be present. - # The apparent best way to do it is to use: - # { - # properties: { - # foo: ... - # }, - # not: { - # required: ['foo'] - # } - # } - # The {required: [foo]} subschema evaluates to true if the foo property is present with any - # value. Wrapping that in a `not` keyword inverts that condition. + # The apparent best way to do it is `not: {required: [foo]}`: the {required: [foo]} + # subschema evaluates to true if the foo property is present with any value, and `not` + # inverts that. Each absent property gets its own `not`, in `allOf`, since + # `not: {required: [foo, bar]}` would only forbid foo and bar together. if value_disallowed: - if "not" not in self: - self["not"] = {} - if "required" not in self["not"]: - self["not"]["required"] = [] - - self["not"]["required"].append(canonical_name) + self.setdefault("allOf", []).append({"not": {"required": [canonical_name]}}) def add_keyword(self, keyword: str, value: Any): if value is None: @@ -623,29 +612,20 @@ def handle_class(self, cls: ClassDefinition) -> None: class_subschema["allOf"] = [] class_subschema["allOf"].extend(rule_subschemas) - if cls.any_of is not None and len(cls.any_of) > 0: - class_subschema["anyOf"] = [self.get_subschema_for_anonymous_class(c, False) for c in cls.any_of] - - if cls.all_of is not None and len(cls.all_of) > 0: - if "allOf" not in class_subschema: - class_subschema["allOf"] = [] - class_subschema["allOf"].extend([self.get_subschema_for_anonymous_class(c, False) for c in cls.all_of]) - - if cls.exactly_one_of is not None and len(cls.exactly_one_of) > 0: - class_subschema["oneOf"] = [self.get_subschema_for_anonymous_class(c, False) for c in cls.exactly_one_of] - - if cls.none_of is not None and len(cls.none_of) > 0: - # properties_required=True so absent slots make their branch fail; otherwise - # properties is vacuously true and `not(anyOf)` rejects instances missing the slot. - new_not = {"anyOf": [self.get_subschema_for_anonymous_class(c, True) for c in cls.none_of]} - if "not" in class_subschema: - existing_not = class_subschema.pop("not") - if "allOf" not in class_subschema: - class_subschema["allOf"] = [] - class_subschema["allOf"].append({"not": existing_not}) - class_subschema["allOf"].append({"not": new_not}) - else: - class_subschema["not"] = new_not + # A class expression constrains every instance of its class, so a class carries the + # expressions of its ancestors and mixins, translated in its own context. + for owner in self.schemaview.class_ancestors(cls.name): + for operator in self.CLASS_EXPRESSION_OPERATORS: + members = getattr(self.schemaview.get_class(owner), operator) or [] + if members: + expression = self.get_subschema_for_class_operator(cls, operator, members, definite=False) + for keyword, value in expression.items(): + if keyword == "allOf": + class_subschema.setdefault("allOf", []).extend(value) + elif keyword not in class_subschema: + class_subschema[keyword] = value + else: + class_subschema.setdefault("allOf", []).append({keyword: value}) class_subschema = self.after_generate_class( ClassResult.model_construct(schema_=class_subschema, source=cls), self.schemaview @@ -671,6 +651,192 @@ def handle_class(self, cls: ClassDefinition) -> None: if key not in self.top_level_schema: self.top_level_schema[key] = value + CLASS_EXPRESSION_OPERATORS = ("any_of", "all_of", "exactly_one_of", "none_of") + _CLASS_OPERATOR_KEYWORDS = {"any_of": "anyOf", "all_of": "allOf", "exactly_one_of": "oneOf"} + + def get_subschema_for_class_operator( + self, cls: ClassDefinition | None, operator: str, members: list[AnonymousClassExpression], definite: bool + ) -> JsonSchema: + """The subschema of the class-level boolean expression *operator* over *members*, for *cls*. + + A condition that doesn't state presence is unknown for an absent slot, + and an expression is violated only when it is definitely false (see + :mod:`linkml.generators.common.class_expression`). With *definite* the + subschema accepts the instances for which the expression is definitely + true, otherwise those for which it is not false. ``none_of`` takes its + members in the opposite form, ``exactly_one_of`` counts the members + that are definitely true, and ``any_of`` / ``all_of`` keep the form. + """ + if operator == "none_of": + return JsonSchema( + {"not": {"anyOf": [self.get_subschema_for_class_expression(cls, m, not definite) for m in members]}} + ) + form = True if operator == "exactly_one_of" else definite + return JsonSchema( + { + self._CLASS_OPERATOR_KEYWORDS[operator]: [ + self.get_subschema_for_class_expression(cls, m, form) for m in members + ] + } + ) + + def get_subschema_for_class_expression( + self, cls: ClassDefinition | None, expr: AnonymousClassExpression, definite: bool + ) -> JsonSchema: + """The subschema of one member *expr* of a class-level boolean expression of *cls*. + + Each slot condition constrains the slot as induced for *cls*, so + ``slot_usage`` applies. Its values must satisfy its value operators, + its ``range`` and its ``range_expression``, the latter in the form of + *expr*, and one of them its ``has_member``; the number of values must + lie within + :func:`~linkml.generators.common.class_expression.value_bounds`, where + an empty list counts as no value. *definite* selects the form, as in + :meth:`get_subschema_for_class_operator`. *cls* is ``None`` for values + that are not instances of a class. + """ + subschema = JsonSchema() + conjuncts: list[JsonSchema] = [] + for slot_name, condition in expr.slot_conditions.items(): + slot = self._class_expression_slot(cls, slot_name) or condition + if self.use_curies: + prop_name = self._curie(slot) + else: + prop_name = self.aliased_slot_name(slot) + values = self._value_subschema(slot, condition, definite) + lower, upper = value_bounds(condition, definite) + member = None + if slot.multivalued and condition.has_member is not None: + member = self.get_subschema_for_member(slot, condition.has_member, definite) + if slot.multivalued and self._inlined_as_dict(slot): + prop = JsonSchema({"type": "object", "additionalProperties": values}) + prop.add_keyword("minProperties", lower or None) + prop.add_keyword("maxProperties", upper) + if member is not None: + prop.setdefault("allOf", []).append(self._some_dict_value(member)) + elif slot.multivalued: + prop = JsonSchema.array_of(values, include_null=False, required=False) + prop.add_keyword("minItems", lower or None) + prop.add_keyword("maxItems", upper) + prop.add_keyword("contains", member) + else: + prop = values + if condition.range is not None: + # the values in the representation of the slot, as a list, a dict or a single value + typed = self.get_subschema_for_slot( + SlotDefinition( + slot.name, + range=condition.range, + multivalued=slot.multivalued, + inlined=slot.inlined, + inlined_as_list=slot.inlined_as_list, + ), + include_null=False, + ) + prop = JsonSchema({"allOf": [prop, typed]}) if prop else typed + if slot.multivalued: + subschema.add_property(prop_name, prop, value_required=lower > 0) + else: + subschema.add_property(prop_name, prop, value_required=lower > 0, value_disallowed=upper == 0) + if lower > 1: + # a single-valued slot holds at most one value + conjuncts.append(JsonSchema({"not": {}})) + for operator in self.CLASS_EXPRESSION_OPERATORS: + members = getattr(expr, operator) or [] + if members: + conjuncts.append(self.get_subschema_for_class_operator(cls, operator, members, definite)) + if expr.is_a is not None: + # `is_a: ` in a class expression requires instances of the expression to be instances of . + conjuncts.append(self.get_subschema_for_slot(AnonymousSlotExpression(range=expr.is_a))) + if conjuncts: + subschema.setdefault("allOf", []).extend(conjuncts) + return subschema + + def _inlined_as_dict(self, slot: SlotDefinition) -> bool: + """Whether the values of multivalued *slot* are inlined as a dict, keyed by their identifier.""" + return ( + self.schemaview.is_inlined(slot) + and not slot.inlined_as_list + and get_range_associated_slots(self.schemaview, slot.range)[0] is not None + ) + + def _value_subschema( + self, + slot: SlotDefinition | AnonymousSlotExpression, + condition: SlotDefinition | AnonymousSlotExpression, + definite: bool, + ) -> JsonSchema: + """The subschema each value of *slot* must satisfy under the value operators of *condition*. + + These include its ``range_expression``, in the form *definite*, but not + its ``range``, which depends on how the values of *slot* are represented. + """ + value_condition = condition + if condition.range_expression is not None: + # translated below, in the form *definite* + value_condition = copy(condition) + value_condition.range_expression = None + values = self.get_subschema_for_slot(value_condition, omit_type=True, include_null=False) + if condition.range_expression is not None: + expression = self.get_subschema_for_range_expression( + condition.range or slot.range, condition.range_expression, definite + ) + values = JsonSchema({"allOf": [values, expression]}) if values else expression + return values + + def get_subschema_for_member( + self, slot: SlotDefinition | AnonymousSlotExpression, member: AnonymousSlotExpression, definite: bool + ) -> JsonSchema: + """The subschema a value of multivalued *slot* satisfies as a member of its ``has_member`` *member*. + + A member is one value, which must satisfy the value operators, the + ``range`` and the ``range_expression`` of *member*, the latter in the + form *definite*. + """ + conjuncts = [self._value_subschema(slot, member, definite)] + if member.range is not None: + conjuncts.append(self._value_type_subschema(slot, member.range)) + conjuncts = [conjunct for conjunct in conjuncts if conjunct] + if len(conjuncts) == 1: + return conjuncts[0] + return JsonSchema({"allOf": conjuncts} if conjuncts else {}) + + def _value_type_subschema(self, slot: SlotDefinition | AnonymousSlotExpression, value_range: str) -> JsonSchema: + """The subschema of one value of range *value_range* as multivalued *slot* represents its values.""" + collection = self.get_subschema_for_slot( + AnonymousSlotExpression( + range=value_range, multivalued=True, inlined=slot.inlined, inlined_as_list=slot.inlined_as_list + ), + include_null=False, + ) + return collection["additionalProperties"] if collection.is_object else collection["items"] + + @staticmethod + def _some_dict_value(member: JsonSchema) -> JsonSchema: + """The subschema of a dict with a value satisfying *member*: not every value fails it.""" + return JsonSchema({"not": {"additionalProperties": {"not": member}}}) + + def _class_expression_slot(self, cls: ClassDefinition | None, slot_name: str) -> SlotDefinition | None: + """The slot a condition of a class expression of *cls* names, as induced for *cls*, if any.""" + sv = self.schemaview + if cls is not None and slot_name in sv.class_slots(cls.name): + return sv.induced_slot(slot_name, cls.name) + return sv.get_slot(slot_name) + + def get_subschema_for_range_expression( + self, value_range: str | None, expression: AnonymousClassExpression, definite: bool + ) -> JsonSchema: + """The subschema a value of range *value_range* must satisfy under *expression*. + + A ``range_expression`` constrains each value as a class-level expression + constrains an instance (:meth:`get_subschema_for_class_expression`): its + conditions constrain the slots of the value as induced for the class + *value_range*, and *definite* selects the form. + """ + sv = self.schemaview + value_class = sv.get_class(value_range) if value_range in sv.all_classes() else None + return self.get_subschema_for_class_expression(value_class, expression, definite) + def get_subschema_for_anonymous_class( self, cls: AnonymousClassExpression, properties_required: bool = False ) -> None | JsonSchema: @@ -679,14 +845,15 @@ def get_subschema_for_anonymous_class( subschema = JsonSchema() for slot in cls.slot_conditions.values(): - if self.use_curies: + # Anonymous slot expressions don't carry the underlying slot's `identifier` or + # `multivalued` flags, so look them up on the schema's slot definition. + base_slot = self.schemaview.get_slot(slot.name) if slot.name else None + if self.use_curies and not (base_slot is not None and base_slot.identifier): prop_name = self._curie(slot) else: prop_name = self.aliased_slot_name(slot) prop = self.get_subschema_for_slot(slot, omit_type=True, include_null=False) - # Anonymous slot expressions don't carry the underlying slot's `multivalued` flag, - # so look it up on the schema's slot definition and wrap so item-level constraints apply. - base_slot = self.schemaview.get_slot(slot.name) if slot.name else None + # Wrap multivalued slots so item-level constraints apply. if base_slot is not None and base_slot.multivalued and not prop.is_array: prop = JsonSchema.array_of(prop, include_null=False, required=False) value_required = False @@ -836,7 +1003,7 @@ def get_value_constraints_for_slot(self, slot: SlotDefinition | AnonymousSlotExp constraints.add_keyword("enum", slot.equals_string_in) if slot.range_expression: - subschema = self.get_subschema_for_anonymous_class(slot.range_expression) + subschema = self.get_subschema_for_range_expression(slot.range, slot.range_expression, definite=False) if subschema: if "allOf" not in constraints: constraints["allOf"] = [] @@ -851,6 +1018,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 get_subschema_for_slot( self, slot: SlotDefinition | AnonymousSlotExpression, @@ -868,6 +1069,7 @@ def get_subschema_for_slot( slot_is_boolean = any([slot.any_of, slot.all_of, slot.exactly_one_of, slot.none_of]) typ = None + inlined_as_dict = False if not omit_type: typ, fmt, reference = self.get_type_info_for_slot_subschema(slot) if slot_is_inlined: @@ -881,6 +1083,7 @@ def get_subschema_for_slot( # if the range class has an ID and the slot is not inlined as a list, then we need to consider # various inlined as dict formats if range_id_slot is not None and not slot.inlined_as_list: + inlined_as_dict = True # At a minimum, the inlined dict can have keys (additionalProps) that are IDs # and the values are the range class but possibly omitting the ID. additionalProps = [JsonSchema.ref_for(reference, identifier_optional=True)] @@ -907,6 +1110,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) @@ -949,13 +1157,21 @@ def get_subschema_for_slot( if prop.is_array: all_element_constraints = self.get_value_constraints_for_slot(slot.all_members) - any_element_constraints = self.get_value_constraints_for_slot(slot.has_member) prop.add_keyword("minItems", slot.minimum_cardinality) prop.add_keyword("maxItems", slot.maximum_cardinality) prop["items"].update(own_constraints) prop["items"].update(all_element_constraints) - if any_element_constraints: - prop["contains"] = any_element_constraints + if slot.has_member is not None: + # At least one value, so not null. + prop["type"] = "array" + prop["contains"] = self.get_subschema_for_member(slot, slot.has_member, definite=False) + elif inlined_as_dict: + # The values of the slot are the values of the dict. + if own_constraints: + prop["additionalProperties"] = JsonSchema({"allOf": [prop["additionalProperties"], own_constraints]}) + if slot.has_member is not None: + member = self.get_subschema_for_member(slot, slot.has_member, definite=False) + prop.setdefault("allOf", []).append(self._some_dict_value(member)) else: prop.update(own_constraints) @@ -1019,11 +1235,15 @@ def handle_class_slot(self, subschema: JsonSchema, cls: ClassDefinition, slot: S slot = self.before_generate_class_slot(slot, cls, self.schemaview) class_id_slot = self.schemaview.get_identifier_slot(cls.name, use_key=True) value_required = ( - slot.required or slot == class_id_slot or slot.value_presence == PresenceEnum(PresenceEnum.PRESENT) + slot.required + or slot == class_id_slot + or slot.value_presence == PresenceEnum(PresenceEnum.PRESENT) + # a member is a value + or (slot.multivalued and slot.has_member is not None) ) value_disallowed = slot.value_presence == PresenceEnum(PresenceEnum.ABSENT) - if self.use_curies: + if self.use_curies and not slot.identifier: prop_name = self._curie(slot) else: prop_name = self.aliased_slot_name(slot) diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 7ba15df672..5bd8ac47cf 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -19,9 +19,10 @@ from linkml import METAMODEL_NAMESPACE_NAME from linkml._version import __version__ +from linkml.generators.common.annotations import declared_annotation from linkml.generators.common.subproperty import is_xsd_anyuri_range from linkml.utils.deprecation import deprecation_warning -from linkml.utils.generator import Generator, shared_arguments +from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments from linkml.utils.language_tags import LanguageTagResolver from linkml_runtime import SchemaView from linkml_runtime.linkml_model.meta import ( @@ -32,6 +33,7 @@ ClassDefinitionName, ClassRule, Definition, + Element, EnumDefinition, EnumDefinitionName, PermissibleValue, @@ -122,6 +124,9 @@ class OwlSchemaGenerator(Generator): """Suffix to add to the schema name to create the ontology URI, e.g. .owl.ttl""" # ObjectVars + diff_stable: bool = False + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" + metadata_profile: MetadataProfile | None = None """Deprecated - use metadata_profiles.""" @@ -316,6 +321,10 @@ def as_graph(self) -> Graph: self.graph.bind(prefix, self.metamodel.namespaces[prefix]) for pfx in schema.prefixes.values(): self.graph.namespace_manager.bind(pfx.prefix_prefix, URIRef(pfx.prefix_reference)) + if self.normalize_prefixes: + normalize_graph_prefixes( + graph, {str(v.prefix_prefix): str(v.prefix_reference) for v in schema.prefixes.values()} + ) graph.add((base, RDF.type, OWL.Ontology)) # Add main schema elements @@ -353,11 +362,11 @@ def serialize(self, **kwargs: Any) -> str: """ self.as_graph() fmt = "turtle" if self.format in ["owl", "ttl"] else self.format - return canonicalize_rdf_graph(self.graph, output_format=fmt) + return canonicalize_rdf_graph(self.graph, output_format=fmt, diff_stable=self.diff_stable) - def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: + def add_metadata(self, e: Element | PermissibleValue, uri: URIRef) -> None: """ - Add annotation properties. + Add annotation properties and explicit metaclass membership. Set the profile attribute to the appropriate OWL profile. Human-readable string literals are language-tagged when @@ -373,6 +382,8 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: sn_mappings = msv.slot_name_mappings() lang = self._resolve_language(e) + self._add_instantiates(e, uri) + # iterate through all the assigned metamodel slots for metaslot_name, metaslot_value in vars(e).items(): if not metaslot_value: @@ -421,9 +432,19 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: obj = Literal(v) self.graph.add((uri, metaslot_uri, obj)) + self._add_annotations(e, uri) + + def _add_annotations(self, e: Element | PermissibleValue, uri: URIRef) -> None: + """Emit explicit annotations on any generated schema resource.""" + this_sv = self.schemaview + lang = self._resolve_language(e) for k, v in e.annotations.items(): if isinstance(v, dict) or isinstance(v, list): continue + declared = declared_annotation(this_sv, e, k, v.value, lang) + if declared is not None: + self.graph.add((uri, *declared)) + continue if ":" not in k: default_prefix = this_sv.schema.default_prefix if default_prefix in this_sv.schema.prefixes: @@ -1080,11 +1101,23 @@ def add_slot(self, slot: SlotDefinition, attribute: bool = False) -> None: for mixin in slot.mixins: self.graph.add((slot_uri, RDFS.subPropertyOf, self._prop_uri(mixin))) + def _add_instantiates(self, element: Element | PermissibleValue, uri: URIRef) -> None: + """Type the emitted schema resource itself, without typing its data instances. + + ``instantiates`` declares metaclass membership in LinkML. In OWL's RDF + mapping, class assertions are ``rdf:type`` triples; a resource also used + as an OWL class or property is interpreted separately as an individual. + """ + for instantiated in element.instantiates: + self.graph.add((uri, RDF.type, URIRef(self.schemaview.expand_curie(instantiated)))) + def add_type(self, typ: TypeDefinition) -> None: type_uri = self._type_uri(typ.name) if typ.from_schema == "https://w3id.org/linkml/types": return + self._add_instantiates(typ, type_uri) + self._add_annotations(typ, type_uri) if self.metaclasses: self.graph.add( ( @@ -1196,6 +1229,8 @@ def add_enum(self, e: EnumDefinition) -> None: pv_node = Literal(pv.text) if pv.meaning: logger.warning(f"Meaning on literal {pv.text} in {e.name} is ignored") + if pv.instantiates: + logger.warning(f"Instantiates on literal {pv.text} in {e.name} is ignored") else: pv_node = self._permissible_value_uri(pv, enum_uri, e) pv_uris.append(pv_node) @@ -1844,6 +1879,15 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef: "specified language tag. Element-level in_language overrides this." ), ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile: str, metadata_profile: str, **kwargs: Any) -> None: """Generate an OWL representation of a LinkML model diff --git a/packages/linkml/src/linkml/generators/rdfgen.py b/packages/linkml/src/linkml/generators/rdfgen.py index 2da1701787..5b8f8dc1c7 100644 --- a/packages/linkml/src/linkml/generators/rdfgen.py +++ b/packages/linkml/src/linkml/generators/rdfgen.py @@ -78,6 +78,9 @@ class RDFGenerator(Generator): uses_schemaloader = True # ObjectVars + diff_stable: bool = False + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" + emit_metadata: bool = False context: list[str] = None original_schema: SchemaDefinition = None @@ -89,7 +92,7 @@ def __post_init__(self): def _data(self, g: Graph) -> str: fmt = "turtle" if self.format == "ttl" else self.format - return canonicalize_rdf_graph(g, output_format=fmt) + return canonicalize_rdf_graph(g, output_format=fmt, diff_stable=self.diff_stable) def end_schema(self, output: str | None = None, context: str = None, **_) -> str: gen = JSONLDGenerator( @@ -137,6 +140,15 @@ def end_schema(self, output: str | None = None, context: str = None, **_) -> str multiple=True, help="JSONLD context file (default: vendored meta.context.jsonld)", ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **kwargs): """Generate an RDF representation of a LinkML model""" diff --git a/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py b/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py index 1210a31be2..7ffd11d45d 100644 --- a/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py +++ b/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py @@ -20,7 +20,7 @@ class ShaclDataType(DataType, Enum): FLOAT = ("float", URIRef("http://www.w3.org/2001/XMLSchema#float")) DOUBLE = ("double", URIRef("http://www.w3.org/2001/XMLSchema#double")) URI = ("uri", URIRef("http://www.w3.org/2001/XMLSchema#anyURI")) - CURIE = ("curi", URIRef("http://www.w3.org/2001/XMLSchema#string")) + CURIE = ("curie", URIRef("http://www.w3.org/2001/XMLSchema#string")) NCNAME = ("ncname", URIRef("http://www.w3.org/2001/XMLSchema#string")) OBJECT_IDENTIFIER = ("objectidentifier", URIRef("http://www.w3.org/ns/shex#iri")) NODE_IDENTIFIER = ("nodeidentifier", URIRef("http://www.w3.org/ns/shex#nonLiteral")) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 4731b9f0b8..920168f1ac 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -1,8 +1,10 @@ import logging +import math import os +import re import string from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, fields import click from jsonasobj2 import JsonObj, as_dict @@ -11,12 +13,26 @@ 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.annotations import declared_annotation +from linkml.generators.common.class_expression import value_bounds +from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range, is_xsd_anyuri_range 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 +from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments from linkml.utils.language_tags import LanguageTagResolver -from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum +from linkml_runtime.linkml_model.meta import ( + AnonymousClassExpression, + AnonymousSlotExpression, + ClassDefinition, + ClassExpression, + ClassRule, + Element, + ElementName, + PresenceEnum, + SlotDefinition, + SlotExpression, +) +from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str @@ -59,6 +75,32 @@ def _validate_message_template(template: str) -> None: ) +_PRESENT = PresenceEnum(PresenceEnum.PRESENT) +_ABSENT = PresenceEnum(PresenceEnum.ABSENT) +_UNCOMMITTED = PresenceEnum(PresenceEnum.UNCOMMITTED) + + +@dataclass +class _RuleSite: + """A rule as it is translated for the shape of a class. + + *cls* is the class whose shape the rule constrains, *owner* the class + that declares it, and *index* its 1-based position among the owner's + rules; *cls* differs from *owner* when the rule is inherited. + """ + + cls: ClassDefinition + owner: str + index: int + rule: ClassRule + + def __str__(self) -> str: + text = f"Rule {self.index} of class {self.owner!r}" + if self.rule.description: + text += f" ({self.rule.description})" + return text + + @dataclass class ShaclGenerator(Generator): """Generate SHACL (Shapes Constraint Language) shapes from a LinkML schema. @@ -142,18 +184,30 @@ class ShaclGenerator(Generator): ignores any per-slot ``in_language``. """ + diff_stable: bool = False + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" + emit_rules: bool = True """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. - When ``True`` (default), recognised rule patterns are translated into - SHACL-SPARQL constraints (``sh:SPARQLConstraint``) on the corresponding - ``sh:NodeShape``. Currently two patterns are recognised: - - * *Boolean guard* — a precondition with ``value_presence: PRESENT`` on a - value slot and a postcondition with ``equals_string: "true"`` on a - boolean flag slot. - * *Exclusive value* — a precondition with ``equals_string`` on a slot and - a postcondition with ``maximum_cardinality`` on the *same* slot. + When ``True`` (default), rules are translated into SHACL-SPARQL + constraints (``sh:SPARQLConstraint``) on the corresponding + ``sh:NodeShape``. Two patterns are recognised first: + + * *Presence implies value* — a precondition with ``value_presence: PRESENT`` + on a guard slot and a postcondition with ``equals_string`` or + ``equals_string_in`` on a target slot holding strings (an enum, or a type + with datatype ``xsd:string``) or booleans. The *boolean guard* is the + case ``equals_string: "true"`` on an ``xsd:boolean``-typed flag. + * *Exclusive value* — a precondition with ``has_member: {equals_string: V}`` + (or a bare ``equals_string: V``) on a slot and a postcondition with + ``maximum_cardinality`` on the *same* slot. + + Any other rule with one postcondition slot is composed from the operators + its conditions use (presence, value comparisons, numeric bounds, + ``range_expression``, ``has_member``). A rule that cannot be translated + exactly is skipped with a warning. A class shape carries the rules of the + class's ancestors and mixins too. See `W3C SHACL §5 `_ and `linkml/linkml#2464 `_. @@ -196,10 +250,13 @@ def generate_header(self) -> str: def serialize(self, **args) -> str: g = self.as_graph() fmt = "turtle" if self.format in ["owl", "ttl"] else self.format - return canonicalize_rdf_graph(g, output_format=fmt) + return canonicalize_rdf_graph(g, output_format=fmt, diff_stable=self.diff_stable) def as_graph(self) -> Graph: sv = self.schemaview + # Problems with rules, collected while the rules are translated for + # every class that has them and reported once each at the end. + self._rule_problems: dict[tuple[str, int, str], tuple[str, list[str]]] = {} g = Graph() g.bind("sh", SH) @@ -207,7 +264,14 @@ def as_graph(self) -> Graph: for pfx in self.schema.prefixes.values(): g.bind(str(pfx.prefix_prefix), pfx.prefix_reference) + if self.normalize_prefixes: + normalize_graph_prefixes( + g, {str(v.prefix_prefix): str(v.prefix_reference) for v in self.schema.prefixes.values()} + ) + self._class_expressions_added: set[tuple[URIRef, str, str]] = set() + self._class_expression_problems: dict[tuple[str, str, str], list[str]] = {} + self._slot_expression_problems: dict[tuple[str, str, str], list[str]] = {} for c in sv.all_classes(imports=not self.exclude_imports).values(): def shape_pv(p, v): @@ -248,12 +312,7 @@ def shape_pv(p, v): self._add_annotations(shape_pv, c) order = 0 for s in sv.class_induced_slots(c.name): - # fixed in linkml-runtime 1.1.3 - if s.name in sv.element_by_schema_map(): - slot_uri = URIRef(sv.get_uri(s, expand=True)) - else: - pfx = sv.schema.default_prefix - slot_uri = URIRef(sv.expand_curie(f"{pfx}:{underscore(s.name)}")) + slot_uri = URIRef(self._slot_iri(s)) pnode = BNode() shape_pv(SH.property, pnode) @@ -323,42 +382,25 @@ def prop_pv_text(p, v): range_list = [] for any in s.any_of: r = any.range - if r in all_classes: - class_node = BNode() + branch = BNode() - def cl_node_pv(p, v): - if v is not None: - g.add((class_node, p, v)) + def branch_pv(p, v): + if v is not None: + g.add((branch, p, v)) - self._add_class(cl_node_pv, r) - range_list.append(class_node) + if r in all_classes: + self._add_class(branch_pv, r) elif r in sv.all_types(): - t_node = BNode() - - def t_node_pv(p, v): - if v is not None: - g.add((t_node, p, v)) - - self._add_type(t_node_pv, r) - range_list.append(t_node) + self._add_type(branch_pv, r) elif r in sv.all_enums(): - en_node = BNode() - - def en_node_pv(p, v): - if v is not None: - g.add((en_node, p, v)) - - self._add_enum(g, en_node_pv, r) - range_list.append(en_node) + self._add_enum(g, branch_pv, r) else: - st_node = BNode() - - def st_node_pv(p, v): - if v is not None: - g.add((st_node, p, v)) - - add_simple_data_type(st_node_pv, r) - range_list.append(st_node) + add_simple_data_type(branch_pv, r) + if any.range_expression is not None: + self._add_range_expression(g, branch_pv, c, s, r, any.range_expression) + if any.pattern: + g.add((branch, SH.pattern, Literal(any.pattern))) + range_list.append(branch) Collection(g, or_node, range_list) else: prop_pv_literal(SH.hasValue, s.equals_number) @@ -371,21 +413,7 @@ def st_node_pv(p, v): f" require range 'string' and not '{r}'" ) - if r in all_classes: - cls_def = sv.get_class(r) - is_any = cls_def and getattr(cls_def, "class_uri", None) == "linkml:Any" - self._add_class(prop_pv, r) - if not is_any: - if sv.get_identifier_slot(r) is not None: - prop_pv(SH.nodeKind, SH.IRI) - else: - prop_pv(SH.nodeKind, SH.BlankNodeOrIRI) - elif r in sv.all_types(): - self._add_type(prop_pv, r) - elif r in sv.all_enums(): - self._add_enum(g, prop_pv, r) - else: - add_simple_data_type(prop_pv, r) + self._add_range(g, prop_pv, r) if s.pattern: prop_pv(SH.pattern, Literal(s.pattern)) if s.equals_string: @@ -398,6 +426,11 @@ def st_node_pv(p, v): # Map subproperty_of to sh:in with slot descendants self._add_subproperty_constraint(g, prop_pv, s) + if s.range_expression is not None: + self._add_range_expression(g, prop_pv, c, s, s.range, s.range_expression) + if s.has_member is not None: + self._add_member(g, prop_pv, c, s) + if s.annotations and self.include_annotations: self._add_annotations(prop_pv, s) @@ -405,239 +438,1331 @@ def st_node_pv(p, v): if default_value: prop_pv(SH.defaultValue, default_value) + self._add_class_expressions(g, class_uri_with_suffix, c) + if self.emit_rules: self._add_rules(g, class_uri_with_suffix, c) + self._report_rule_problems() + self._report_class_expression_problems() + self._report_slot_expression_problems() return g LINKML_ANY_URI = "https://w3id.org/linkml/Any" # ------------------------------------------------------------------- - # Rules → sh:sparql + # Class expressions → sh:or / sh:and / sh:xone / sh:not # ------------------------------------------------------------------- - def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: - """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. + # The SHACL logical constraint component, taking a list, of each list operator. + _LIST_OPERATORS = {"any_of": SH["or"], "all_of": SH["and"], "exactly_one_of": SH.xone} + _CLASS_EXPRESSION_OPERATORS = ("any_of", "all_of", "exactly_one_of", "none_of") - Each recognised rule is converted into an ``sh:SPARQLConstraint`` - attached to *shape_uri*. Unrecognised patterns are logged at - ``DEBUG`` level and silently skipped. + # The fields of an anonymous class expression that carry meaning, derived from + # the metamodel, so that a new semantic field is reported as untranslatable + # instead of being ignored. Every other field is metadata. + _CLASS_EXPRESSION_FIELDS = frozenset(f.name for f in fields(ClassExpression)) | {"is_a"} - Currently recognised patterns: + # Slot-condition fields translated by _slot_condition_shape: those deciding + # whether the slot is present, and those constraining its values. + _SLOT_CONDITION_PRESENCE_FIELDS = frozenset( + {"required", "value_presence", "minimum_cardinality", "maximum_cardinality", "exact_cardinality"} + ) + _SLOT_CONDITION_VALUE_FIELDS = frozenset( + { + "minimum_value", + "maximum_value", + "pattern", + "equals_string", + "equals_string_in", + "equals_number", + "range", + "range_expression", + } + ) + # has_member, which asks for at least one value satisfying a value expression, + # does both. + _SLOT_CONDITION_FIELDS = _SLOT_CONDITION_PRESENCE_FIELDS | _SLOT_CONDITION_VALUE_FIELDS | {"has_member"} + + # Parameters a shape may have at most one value of: sh:minInclusive and + # sh:maxInclusive (SHACL §4.3), sh:in (§4.8.3), sh:datatype and sh:nodeKind + # (§4.1), and sh:pattern, as a parameter of a component with more than one + # parameter (§2.1.1). A condition that needs one of them twice gets the + # second value in an sh:and member. + _SINGLE_VALUE_PARAMETERS = frozenset( + {SH.minInclusive, SH.maxInclusive, SH["in"], SH.pattern, SH.datatype, SH.nodeKind} + ) - * **Boolean guard** — a *precondition* with - ``value_presence: PRESENT`` on a value slot and a *postcondition* - with ``equals_string: "true"`` on a boolean flag slot. + # Fields on a slot condition / class expression that carry no constraint + # semantics: they never change which instances satisfy the condition, so + # they are ignored by the operator accounting below. Anything set on a + # condition that is neither here nor explicitly translated by a converter + # makes the rule untranslatable — the converters must SKIP such a rule + # rather than emit a query that silently drops a conjunct (which would + # widen the trigger or narrow the check: a mis-translation, not a skip). + # Derived from the metamodel: the metadata every ``element`` carries, minus + # anything that is a ``slot_expression`` operator. + _NON_OPERATOR_FIELDS = frozenset(f.name for f in fields(Element)) - frozenset( + f.name for f in fields(SlotExpression) + ) - * **Exclusive value** — a *precondition* with ``equals_string`` on - a slot and a *postcondition* with ``maximum_cardinality`` on the - *same* slot. Enforces that when a specific value is present in a - multivalued slot, the total number of values must not exceed the - given cardinality (typically 1 for mutual exclusion). + @classmethod + def _set_operator_fields( + cls, condition: SlotDefinition | AnonymousSlotExpression | AnonymousClassExpression + ) -> set[str]: + """Return the names of the constraint-bearing fields actually set on a + rule condition or class expression. + + A field counts as *set* when it is not ``None`` and not an empty + collection (SchemaView materialises unset multivalued fields as empty + lists / dicts). Scalars are never judged by truthiness, so legitimate + falsy constraints such as ``minimum_value: 0`` or + ``equals_string: ""`` still count as set. Metadata fields + (:data:`_NON_OPERATOR_FIELDS`) are excluded. + + The converters compare this set against the exact operator set they + translate and skip the rule on any mismatch, so an unrecognised (or + future-metamodel) operator can never be silently dropped. + """ + return { + name + for name, value in vars(condition).items() + if not name.startswith("_") + and name not in cls._NON_OPERATOR_FIELDS + and value is not None + and not (isinstance(value, list | dict) and not value) + } - See `W3C SHACL §5 `_. + def _add_class_expressions(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: + """Emit the class-level boolean expressions of *cls* as SHACL logical constraints. + + Each operator is mapped to the SHACL logical constraint component with the + same semantics (`SHACL §4.6 `_): + + * ``any_of`` → ``sh:or``, ``all_of`` → ``sh:and``, ``exactly_one_of`` → + ``sh:xone``, each over a list of the member shapes; + * ``none_of`` → one ``sh:not`` per member. A shape's values of ``sh:not`` + are separate constraints that all apply (SHACL §2.1.1), so the node must + conform to none of the members. + + Every member becomes an anonymous node shape: ``is_a`` gives ``sh:class`` + for a class and the value constraint of a type or an enum otherwise, + each slot condition gives an ``sh:property`` on the path of the slot as + induced for *cls* (so ``slot_usage`` applies), and nested expressions + recurse. + + The specification doesn't say what a slot condition means for an absent + slot. As with an SQL CHECK constraint (ISO/IEC 9075), which is + satisfied unless its condition is false, a class expression is violated + only when it is definitely false, and a condition that doesn't state + presence (:func:`~linkml.generators.common.class_expression.states_presence`) is unknown + for an absent slot. + Each operator is therefore translated in its "not false" form, and, + under a negation, in its "definitely true" form, where such a condition + requires its slot. ``exactly_one_of`` holds when exactly one member is + definitely true. + + A class expression constrains every instance of its class, so the shape + of *cls* carries the expressions of its ancestors and mixins as well, as + it carries their slots. Each is translated in the context of *cls*, + where ``slot_usage`` may refine a slot it names. Classes that share a + ``class_uri`` share one shape, which carries each expression once. + + An operator whose members use anything that cannot be translated is + skipped as a whole, with a warning: dropping one member would change what + the operator admits. + """ + sv = self.schemaview + for owner in sv.class_ancestors(cls.name): + for operator in self._CLASS_EXPRESSION_OPERATORS: + members = getattr(sv.get_class(owner), operator, None) or [] + if not members or (shape_uri, owner, operator) in self._class_expressions_added: + continue + self._class_expressions_added.add((shape_uri, owner, operator)) + reason = next(filter(None, (self._untranslatable(cls, m) for m in members)), None) + if reason is not None: + classes = self._class_expression_problems.setdefault((owner, operator, reason), []) + if cls.name not in classes: + classes.append(cls.name) + continue + self._add_logical_constraint(g, shape_uri, cls, operator, members, definite=False) + + def _report_class_expression_problems(self) -> None: + """Warn once about each class expression that is not translated, naming the + class shapes it is missing from unless that is only the declaring class.""" + for (owner, operator, reason), classes in self._class_expression_problems.items(): + shapes = "" if classes == [owner] else f" (in the shapes of {', '.join(map(repr, classes))})" + logger.warning( + "Class %r: %s is not translated to SHACL, because it uses %s%s.", owner, operator, reason, shapes + ) + + def _add_logical_constraint( + self, + g: Graph, + subject: URIRef | BNode, + cls: ClassDefinition | None, + operator: str, + members: list[AnonymousClassExpression], + definite: bool, + ) -> None: + """Add the logical constraint for *operator* over *members* to *subject*. + + With *definite* the constraint holds when the expression is definitely + true, otherwise when it is not false. ``none_of`` gives one ``sh:not`` + per member, in the opposite form, since an expression is not false + exactly when its negation is not definitely true. ``exactly_one_of`` + counts the members that are definitely true, in either form. The + other list operators keep the form. """ - if not cls.rules: + if operator == "none_of": + for member in members: + g.add((subject, SH["not"], self._class_expression_shape(g, cls, member, not definite))) return + predicate = self._LIST_OPERATORS[operator] + member_form = True if operator == "exactly_one_of" else definite + shapes = [self._class_expression_shape(g, cls, m, member_form) for m in members] + list_node = BNode() + Collection(g, list_node, shapes) + g.add((subject, predicate, list_node)) + + def _class_expression_shape( + self, g: Graph, cls: ClassDefinition | None, expr: AnonymousClassExpression, definite: bool + ) -> BNode: + """Build the anonymous node shape for one class expression *expr*, in its + "definitely true" form with *definite*, otherwise its "not false" form.""" + node = BNode() + + def node_pv(p, v): + if v is not None: + g.add((node, p, v)) + + if expr.title is not None: + node_pv(RDFS.label, Literal(expr.title, lang=self._resolve_language(expr))) + if expr.description is not None: + node_pv(RDFS.comment, Literal(expr.description, lang=self._resolve_language(expr))) + if expr.is_a is not None: + if expr.is_a in self.schemaview.all_classes(): + self._add_class(node_pv, expr.is_a) + else: + self._add_range(g, node_pv, expr.is_a) + for slot_name, condition in expr.slot_conditions.items(): + node_pv(SH.property, self._slot_condition_shape(g, cls, slot_name, condition, definite)) + for operator in self._CLASS_EXPRESSION_OPERATORS: + members = getattr(expr, operator) or [] + if members: + self._add_logical_constraint(g, node, cls, operator, members, definite) + return node + + def _slot_condition_shape( + self, g: Graph, cls: ClassDefinition, slot_name: str, condition: SlotDefinition, definite: bool + ) -> BNode: + """Build the property shape for the condition on *slot_name*, in its + "definitely true" form with *definite*, otherwise its "not false" form.""" + slot = self._condition_slot(cls, slot_name) + pnode = BNode() + prop_pv, finish = self._shape_parameters(g, pnode) + + prop_pv(SH.path, URIRef(self._slot_iri(slot))) + if condition.title is not None: + prop_pv(SH.name, Literal(condition.title, lang=self._resolve_language(condition))) + if condition.description is not None: + prop_pv(SH.description, Literal(condition.description, lang=self._resolve_language(condition))) + + lower, upper = value_bounds(condition, definite) + if lower: + prop_pv(SH.minCount, Literal(lower)) + if upper is not None: + prop_pv(SH.maxCount, Literal(upper)) + + self._add_value_constraints(g, prop_pv, condition, condition.range or slot.range, definite) + if condition.has_member is not None: + prop_pv(SH.qualifiedValueShape, self._member_shape(g, slot, condition.has_member, definite)) + prop_pv(SH.qualifiedMinCount, Literal(1)) + finish() + return pnode + + def _shape_parameters(self, g: Graph, node: BNode) -> tuple[Callable, Callable[[], None]]: + """A function adding a parameter to the shape *node*, and one to call once all are added. + + A shape may have at most one value of each of :attr:`_SINGLE_VALUE_PARAMETERS`. + A further value goes into an ``sh:and`` member shape of its own, where it + applies to the same values, as it would on *node* itself. + """ + repeated = [] + def add(p, v): + if v is None: + return + if p in self._SINGLE_VALUE_PARAMETERS and (node, p, None) in g: + repeated.append((p, v)) + else: + g.add((node, p, v)) + + def finish(): + if repeated: + members = [] + for p, v in repeated: + member = BNode() + g.add((member, p, v)) + members.append(member) + and_node = BNode() + Collection(g, and_node, members) + g.add((node, SH["and"], and_node)) + + return add, finish + + def _add_value_constraints( + self, + g: Graph, + pv: Callable, + condition: SlotDefinition | AnonymousSlotExpression, + value_range: ElementName | None, + definite: bool, + ) -> None: + """Add the constraints that *condition* places on each value of range *value_range*. + + These are the value operators, ``range`` and ``range_expression``, the + latter in its "definitely true" form with *definite*. + """ + if condition.minimum_value is not None: + pv(SH.minInclusive, Literal(condition.minimum_value)) + if condition.maximum_value is not None: + pv(SH.maxInclusive, Literal(condition.maximum_value)) + if condition.pattern is not None: + pv(SH.pattern, Literal(condition.pattern)) + for values in ( + [condition.equals_string] if condition.equals_string is not None else [], + condition.equals_string_in, + ): + if values: + in_node = BNode() + Collection(g, in_node, self._string_value_terms(value_range, values)) + pv(SH["in"], in_node) + if condition.equals_number is not None: + # A value comparison, unlike the slot loop's sh:hasValue: 5 matches 5.0, + # and like every other value constraint in a condition it holds when the + # slot is absent. + pv(SH.minInclusive, Literal(condition.equals_number)) + pv(SH.maxInclusive, Literal(condition.equals_number)) + if condition.range is not None: + self._add_range(g, pv, condition.range) + if condition.range_expression is not None: + pv(SH.node, self._range_expression_shape(g, value_range, condition.range_expression, definite)) + + def _member_shape(self, g: Graph, slot: SlotDefinition, member: AnonymousSlotExpression, definite: bool) -> BNode: + """Build the node shape a value of *slot* must conform to as a member of its ``has_member``. + + ``sh:qualifiedValueShape`` with ``sh:qualifiedMinCount 1`` (SHACL §4.7.3) + then asks for at least one such value, while the other values need only + satisfy the slot's own constraints. *definite* selects the form of a + ``range_expression`` of *member*. + """ + node = BNode() + pv, finish = self._shape_parameters(g, node) + self._add_value_constraints(g, pv, member, member.range or slot.range, definite) + finish() + return node + + def _string_value_terms(self, r: ElementName | None, values: list[str]) -> list[URIRef | Literal]: + """The RDF terms of the ``equals_string`` / ``equals_string_in`` *values* of a slot with range *r*. + + Permissible values of an enum are rendered as :meth:`_add_enum` renders them, as + the IRI of their ``meaning`` where they have one; anything else is a plain literal. + """ sv = self.schemaview - for rule in cls.rules: - if getattr(rule, "deactivated", False): - continue + if r in sv.all_enums(): + pvs = sv.get_enum(r).permissible_values + return [ + URIRef(sv.expand_curie(pvs[v].meaning)) if v in pvs and pvs[v].meaning else Literal(v) for v in values + ] + return [Literal(v) for v in values] + + def _condition_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: + """The slot a condition of *cls* names, as induced for *cls*, or ``None`` if there is none.""" + try: + return self.schemaview.induced_slot(slot_name, cls.name) + except ValueError: + return None - if getattr(rule, "bidirectional", False): - logger.warning( - "Rule in class %r has bidirectional=true; " - "SHACL-SPARQL generation does not support bidirectional rules. " - "Skipping this rule entirely.", - cls.name, - ) - continue + def _type_uri(self, r: ElementName | None) -> str | None: + """The expanded datatype IRI of type range *r*, or ``None`` when *r* is not a type. - if getattr(rule, "open_world", False): - logger.warning( - "Rule in class %r has open_world=true; " - "SHACL operates under closed-world assumption. " - "The constraint is emitted but may not match open-world semantics.", - cls.name, - ) + Resolved through the induced type, so a type derived with ``typeof`` + inherits the ``uri`` of its ancestor. A built-in type name in a schema + that does not import ``linkml:types`` resolves as the main slot loop + resolves it (:class:`ShaclDataType`). + """ + sv = self.schemaview + if r in sv.all_types(): + return sv.get_uri(sv.induced_type(r), expand=True) + builtin = next((t for t in ShaclDataType if t.linkml_type == r), None) + return str(builtin.uri_ref) if builtin is not None else None + + def _is_string_range(self, r: ElementName | None) -> bool: + """Whether a slot with range *r* holds strings, which ``equals_string`` compares against. + + True for an enum, whose permissible values are rendered as their + ``meaning`` IRI or as a plain literal (as :meth:`_add_enum` renders + them); for a type whose datatype is ``xsd:string``, whose values are + plain literals; and for no range at all, whose values are untyped and + compared as strings, as the JSON Schema generator compares them. A + type with any other datatype, including one derived from ``string`` + (``xsd:anyURI``, ``xsd:token``, ...), holds typed literals or IRIs + that a string literal does not match. + """ + if r is None or r in self.schemaview.all_enums(): + return True + return self._type_uri(r) == str(XSD.string) - if getattr(rule, "elseconditions", None): - logger.warning( - "Rule in class %r has elseconditions; " - "only the forward (if/then) branch is emitted as sh:sparql. " - "The else branch cannot be represented in SHACL-SPARQL.", - cls.name, - ) + def _untranslatable(self, cls: ClassDefinition | None, expr: AnonymousClassExpression) -> str | None: + """Return what in class expression *expr* of *cls* cannot be translated, or ``None``. - sparql_query = self._rule_to_sparql(sv, cls, rule) - if sparql_query is None: - logger.debug( - "Skipping unsupported rule pattern in class %r: %s", - cls.name, - getattr(rule, "description", "(no description)"), - ) - continue + *cls* is ``None`` when *expr* constrains values that are not instances of + a class, which have no slots for a condition to constrain. + """ + unknown = self._set_operator_fields(expr) - self._CLASS_EXPRESSION_FIELDS + if unknown: + return f"'{sorted(unknown)[0]}'" + if expr.is_a is not None and not self._is_known_range(expr.is_a): + return f"is_a '{expr.is_a}', which is not a known class, type or enum" + for slot_name, condition in expr.slot_conditions.items(): + unknown = self._set_operator_fields(condition) - self._SLOT_CONDITION_FIELDS + if unknown: + return f"'{sorted(unknown)[0]}' in the condition on slot '{slot_name}'" + if cls is None: + return f"a condition on slot '{slot_name}' of values that are not class instances" + slot = self._condition_slot(cls, slot_name) + if slot is None: + return f"a condition on '{slot_name}', which is not a slot" + if slot.identifier: + # An identifier is the node's IRI, not a property arc. + return f"a condition on the identifier slot '{slot_name}'" + reason = self._value_untranslatable(condition, slot, f"the condition on slot '{slot_name}'") + if reason is not None: + return reason + if condition.has_member is not None: + reason = self._member_untranslatable(slot, condition.has_member) + if reason is not None: + return reason + for operator in self._CLASS_EXPRESSION_OPERATORS: + for member in getattr(expr, operator) or []: + reason = self._untranslatable(cls, member) + if reason is not None: + return reason + return None - constraint = BNode() - g.add((shape_uri, SH.sparql, constraint)) - g.add((constraint, RDF.type, SH.SPARQLConstraint)) + def _value_untranslatable( + self, condition: SlotDefinition | AnonymousSlotExpression, slot: SlotDefinition, where: str + ) -> str | None: + """Return what in the value constraints of *condition* on *slot* cannot be translated, or ``None``. - message = getattr(rule, "description", None) - if message: - g.add((constraint, SH.message, Literal(message))) + *where* names *condition* in the explanation. + """ + if condition.range is not None and not self._is_known_range(condition.range): + return f"the unknown range '{condition.range}' in {where}" + value_range = condition.range or slot.range + if condition.range_expression is not None: + reason = self._untranslatable(self._value_class(value_range), condition.range_expression) + if reason is not None: + return reason + if (condition.equals_string is not None or condition.equals_string_in) and not self._is_string_range( + value_range + ): + return f"equals_string in {where}, whose range '{value_range}' does not hold strings" + return None - g.add((constraint, SH.select, Literal(sparql_query))) + def _member_untranslatable(self, slot: SlotDefinition, member: AnonymousSlotExpression) -> str | None: + """Return what in the ``has_member`` *member* of *slot* cannot be translated, or ``None``. - def _rule_to_sparql(self, sv, cls: ClassDefinition, rule) -> str | None: - """Convert a ``ClassRule`` to a SPARQL SELECT query string. + A member is one value, so only value constraints apply to it. + """ + if not slot.multivalued: + return f"has_member on slot '{slot.name}', which is not multivalued" + unknown = self._set_operator_fields(member) - self._SLOT_CONDITION_VALUE_FIELDS + if unknown: + return f"'{sorted(unknown)[0]}' in the has_member of slot '{slot.name}'" + return self._value_untranslatable(member, slot, f"the has_member of slot '{slot.name}'") + + def _value_class(self, value_range: ElementName | None) -> ClassDefinition | None: + """The class whose instances a range *value_range* holds, or ``None`` for any other range.""" + sv = self.schemaview + return sv.get_class(value_range) if value_range in sv.all_classes() else None + + def _range_expression_shape( + self, g: Graph, value_range: ElementName | None, expression: AnonymousClassExpression, definite: bool + ) -> BNode: + """Build the node shape a value of range *value_range* must conform to under *expression*. + + A ``range_expression`` constrains each value as a class-level expression + constrains an instance: its conditions constrain the slots of the value + as induced for the class *value_range*, and *definite* selects the form, + as in :meth:`_class_expression_shape`. ``sh:node`` (SHACL §4.7.1) + applies the shape to each value alongside the range's own constraint, + and only to the values of this slot. + """ + return self._class_expression_shape(g, self._value_class(value_range), expression, definite) - Returns ``None`` when the rule does not match any supported pattern. + def _add_range_expression( + self, + g: Graph, + prop_pv: Callable, + cls: ClassDefinition, + slot: SlotDefinition, + value_range: ElementName | None, + expression: AnonymousClassExpression, + ) -> None: + """Constrain each value of range *value_range* of *slot*, in the shape of *cls*, by *expression*. + + The expression is read in its "not false" form. One that cannot be + translated is skipped with a warning, as a class-level expression is. """ - pre = getattr(rule, "preconditions", None) - post = getattr(rule, "postconditions", None) - if not pre or not post: - return None + reason = self._untranslatable(self._value_class(value_range), expression) + if reason is not None: + self._slot_expression_problem(cls, slot, "range_expression", reason) + return + prop_pv(SH.node, self._range_expression_shape(g, value_range, expression, definite=False)) - pre_slots = getattr(pre, "slot_conditions", None) or {} - post_slots = getattr(post, "slot_conditions", None) or {} + def _add_member(self, g: Graph, prop_pv: Callable, cls: ClassDefinition, slot: SlotDefinition) -> None: + """Ask for at least one value of *slot*, in the shape of *cls*, satisfying its ``has_member``. - # Pattern: boolean guard - # preconditions: exactly one slot with value_presence PRESENT - # postconditions: exactly one slot with equals_string "true" - if len(pre_slots) == 1 and len(post_slots) == 1: - pre_slot_name = next(iter(pre_slots)) - post_slot_name = next(iter(post_slots)) + Having no value, an absent slot fails it. A ``has_member`` that cannot be + translated is skipped with a warning. + """ + reason = self._member_untranslatable(slot, slot.has_member) + if reason is not None: + self._slot_expression_problem(cls, slot, "has_member", reason) + return + prop_pv(SH.qualifiedValueShape, self._member_shape(g, slot, slot.has_member, definite=False)) + prop_pv(SH.qualifiedMinCount, Literal(1)) + + def _slot_expression_problem(self, cls: ClassDefinition, slot: SlotDefinition, field: str, reason: str) -> None: + """Record that *field* of *slot* is left out of the shape of *cls* because of *reason*.""" + classes = self._slot_expression_problems.setdefault((slot.name, field, reason), []) + if cls.name not in classes: + classes.append(cls.name) + + def _report_slot_expression_problems(self) -> None: + """Warn once about each ``range_expression`` or ``has_member`` of a slot that is + not translated, naming the class shapes it is missing from.""" + for (slot_name, field, reason), classes in self._slot_expression_problems.items(): + logger.warning( + "Slot %r: %s is not translated to SHACL, because it uses %s (in the shapes of %s).", + slot_name, + field, + reason, + ", ".join(map(repr, classes)), + ) - pre_cond = pre_slots[pre_slot_name] - post_cond = post_slots[post_slot_name] + def _is_known_range(self, r: ElementName) -> bool: + """Whether *r* names a class, type or enum of the schema, or a built-in type.""" + sv = self.schemaview + return ( + r in sv.all_classes() + or r in sv.all_types() + or r in sv.all_enums() + or any(datatype.linkml_type == r for datatype in ShaclDataType) + ) - # Note: PresenceEnum.PRESENT is a PermissibleValue, but parsed schemas - # return PresenceEnum instances — wrapping ensures type-compatible comparison. - is_value_present = getattr(pre_cond, "value_presence", None) == PresenceEnum(PresenceEnum.PRESENT) - is_flag_true = getattr(post_cond, "equals_string", None) == "true" + # ------------------------------------------------------------------- + # Rules → sh:sparql + # ------------------------------------------------------------------- + + def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: + """Emit ``sh:sparql`` constraints from the LinkML ``rules`` that apply to *cls*. - if is_value_present and is_flag_true: - return self._build_boolean_guard_sparql(sv, cls, post_slot_name, pre_slot_name) + Each recognised rule is converted into an ``sh:SPARQLConstraint`` + attached to *shape_uri*. Currently recognised patterns: + + * **Presence implies value** — a *precondition* with + ``value_presence: PRESENT`` on a guard slot and a *postcondition* + with ``equals_string`` or ``equals_string_in`` on a target slot. + When the guard is present, the target must be present and hold one + of the allowed values. The target must hold strings (an enum, or a + type with datatype ``xsd:string``) or booleans (``xsd:boolean``). + The **boolean guard** is the case ``equals_string: "true"`` on a + boolean flag. + + * **Exclusive value** — a *precondition* with ``has_member: + {equals_string: V}`` on a slot and a *postcondition* with + ``maximum_cardinality`` on the *same* slot. When V is one of the + slot's values, the slot holds at most that many values (typically 1 + for mutual exclusion). A bare ``equals_string: V`` precondition on a + multivalued slot is read the same way, with a warning, although the + specification applies ``equals_string`` to all members of a + collection. + + * **Composed** — any other rule with one postcondition slot is + composed from the operators its conditions use: presence, + ``equals_string(_in)`` and numeric bounds on each value, a + ``range_expression`` on the slot's class, and ``has_member`` (see + :meth:`_compose_rule_sparql`). + + Apart from that reading, every emitted constraint translates its rule + exactly. A rule that cannot be translated exactly is skipped with a + warning naming the reason: an operator the translations do not + support, a condition on an unknown or identifier slot, a value the + slot's range cannot hold (see :meth:`_value_terms`), or + ``bidirectional``. Of a rule with ``elseconditions`` the forward + (if/then) direction is emitted, exactly, and a warning reports the + else branch as not enforced. + + A rule applies to "all members of this class" (metamodel ``rules``), + so the shape of a class carries the rules of its ancestors and mixins + as well, as it carries their slots, and as the JSON Schema generator + applies them. Each rule is translated in the context of *cls*, where + ``slot_usage`` may refine a slot the rule references. + + See `W3C SHACL §5 `_. + """ + sv = self.schemaview + for owner in sv.class_ancestors(cls.name): + for index, rule in enumerate(sv.get_class(owner).rules, start=1): + self._add_rule(g, shape_uri, _RuleSite(cls, owner, index, rule)) + + def _add_rule(self, g: Graph, shape_uri: URIRef, site: _RuleSite) -> None: + """Emit the ``sh:SPARQLConstraint`` of the rule at *site* on *shape_uri*, unless it is skipped.""" + rule = site.rule + if rule.deactivated: + return + if rule.bidirectional: + self._skip_rule(site, "bidirectional rules are not supported") + return + sparql_query = self._rule_to_sparql(site) + if sparql_query is None: + return + if rule.elseconditions is not None: + self._warn_rule( + site, "its elseconditions are not enforced; SHACL-SPARQL generation emits the if/then direction only" + ) + message = Literal(rule.description, lang=self._resolve_language(rule)) if rule.description else None + if self._has_sparql_constraint(g, shape_uri, Literal(sparql_query), message): + return + + constraint = BNode() + g.add((shape_uri, SH.sparql, constraint)) + g.add((constraint, RDF.type, SH.SPARQLConstraint)) + if message is not None: + g.add((constraint, SH.message, message)) + g.add((constraint, SH.select, Literal(sparql_query))) + + @staticmethod + def _has_sparql_constraint(g: Graph, shape_uri: URIRef, query: Literal, message: Literal | None) -> bool: + """Whether the shape *shape_uri* already carries the constraint with *query* and *message*. + + Classes that share a ``class_uri`` share one shape when shapes are named + by ``class_uri`` (the default), so a rule they all inherit would + otherwise be added once per class and reported twice. + """ + return any( + (constraint, SH.select, query) in g and set(g.objects(constraint, SH.message)) == {message} - {None} + for constraint in g.objects(shape_uri, SH.sparql) + ) + + def _warn_rule(self, site: _RuleSite, problem: str) -> None: + """Record *problem* with the rule at *site* for the shape of ``site.cls``. + + A rule is translated once for every class that has it, declared or + inherited, so the same problem can arise several times; it is reported + once, by :meth:`_report_rule_problems`. + """ + _, classes = self._rule_problems.setdefault((site.owner, site.index, problem), (str(site), [])) + if site.cls.name not in classes: + classes.append(site.cls.name) + + def _report_rule_problems(self) -> None: + """Log each recorded rule problem once, naming the class shapes it affects + unless that is only the class declaring the rule.""" + for (owner, _, problem), (rule, classes) in self._rule_problems.items(): + shapes = "" if classes == [owner] else f" (in the shapes of {', '.join(map(repr, classes))})" + logger.warning("%s: %s%s.", rule, problem, shapes) + + def _skip_rule(self, site: _RuleSite, reason: str) -> None: + """Log that the rule at *site* is not translated, and why. + + This replaces the problems recorded so far while translating the rule + for the shape of ``site.cls``, which describe a constraint that is not + emitted. + """ + for key, (_, classes) in list(self._rule_problems.items()): + if key[:2] == (site.owner, site.index) and site.cls.name in classes: + classes.remove(site.cls.name) + if not classes: + del self._rule_problems[key] + self._warn_rule(site, f"skipped, because {reason}") + + # The lexical space of xsd:boolean and the value each lexical form maps to + # (XML Schema 1.1 Part 2 §3.3.2.2, ), + # as SPARQL boolean literals. + _XSD_BOOLEAN_LEXICAL = {"true": "true", "1": "true", "false": "false", "0": "false"} + + # Prefixes the reason a rule is skipped when it uses an operator, or a + # combination of conditions, that no translation supports. + _NO_PATTERN = ( + "its conditions match none of the translated patterns (presence implies value, exclusive value, " + "and the compositional translation)" + ) - # Pattern: exclusive value - # preconditions: slot X has equals_string (a specific enum value) - # postconditions: same slot X has maximum_cardinality N - # Semantics: "If value V is present in slot X, then X has at most N values." - pre_equals = getattr(pre_cond, "equals_string", None) - post_max_card = getattr(post_cond, "maximum_cardinality", None) + def _rule_to_sparql(self, site: _RuleSite) -> str | None: + """Translate the rule at *site* to a SPARQL SELECT query. + + Returns ``None``, after a warning naming the reason, when the rule + matches no supported pattern exactly. Each pattern requires its + conditions to set **exactly** the operators it translates; a rule whose + pre/postconditions carry anything more (extra scalar operators, + expression-level ``any_of``/``all_of``/``none_of``/``exactly_one_of``, + ...) is skipped rather than partially translated, since dropping a term + would widen the precondition (false positives) or weaken the + postcondition (false negatives). + """ + pre, post = site.rule.preconditions, site.rule.postconditions + if pre is None or post is None: + self._skip_rule(site, "only rules with preconditions and postconditions are translated") + return None + for side, expression in (("preconditions", pre), ("postconditions", post)): + untranslated = self._set_operator_fields(expression) - {"slot_conditions"} + if untranslated: + self._skip_rule(site, f"its {side} use {', '.join(sorted(untranslated))}") + return None + if not expression.slot_conditions: + self._skip_rule(site, f"its {side} constrain no slot") + return None + if len(pre.slot_conditions) != 1 or len(post.slot_conditions) != 1: + return self._compose_rule_sparql(site, pre.slot_conditions, post.slot_conditions) + + ((pre_name, pre_cond),) = pre.slot_conditions.items() + ((post_name, post_cond),) = post.slot_conditions.items() + pre_slot = self._rule_condition_slot(site, pre_name) + post_slot = self._rule_condition_slot(site, post_name) if pre_slot is not None else None + if pre_slot is None or post_slot is None: + return None + pre_ops = self._set_operator_fields(pre_cond) + post_ops = self._set_operator_fields(post_cond) + + # Presence implies value: "if the guard is present, the target must be + # present and hold one of the allowed values". The boolean guard is + # the case `equals_string: "true"` on a boolean flag. + if ( + pre_ops == {"value_presence"} + and pre_cond.value_presence == _PRESENT + and post_ops in ({"equals_string"}, {"equals_string_in"}) + ): + values = [post_cond.equals_string] if post_ops == {"equals_string"} else list(post_cond.equals_string_in) + terms = self._value_terms(site, post_slot, values) + if terms is None: + return None + return self._build_presence_implies_value_sparql(pre_slot, post_slot, terms, bool(site.rule.open_world)) + + # Exclusive value: "if V is one of the values of slot X, then X has at + # most N values". + if post_ops == {"maximum_cardinality"} and pre_slot.name == post_slot.name: + exclusive = self._exclusive_value(site, pre_slot, pre_cond, pre_ops) + if exclusive is not None: + terms = self._value_terms(site, pre_slot, [exclusive]) + if terms is None: + return None + return self._build_exclusive_value_sparql(pre_slot, terms[0], int(post_cond.maximum_cardinality)) + + return self._compose_rule_sparql(site, pre.slot_conditions, post.slot_conditions) + + def _exclusive_value( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, operators: set[str] + ) -> str | None: + """The value V of an exclusive-value precondition on *slot*, or ``None`` if *condition* is not one. + + ``has_member: {equals_string: V}`` states "V is one of the values" + (metamodel ``has_member``: "at least one member satisfying the + condition"). A bare ``equals_string: V`` on a multivalued slot is read + the same way, as the pattern always has, with a warning: the + specification applies a slot constraint to all members of a collection + (``05validation.md``), under which the rule would mean something else. + Every other rule reads it that way (:meth:`_compose_rule_sparql`). + """ + if operators == {"has_member"} and self._set_operator_fields(condition.has_member) == {"equals_string"}: + return condition.has_member.equals_string + if operators == {"equals_string"}: + if slot.multivalued: + self._warn_rule( + site, + f"its precondition equals_string on the multivalued slot {slot.name!r} is read as " + "'one of the values equals', which has_member: {equals_string: ...} states explicitly; " + "the specification applies equals_string to all members of a collection", + ) + return condition.equals_string + return None - if pre_equals is not None and post_max_card is not None and pre_slot_name == post_slot_name: - return self._build_exclusive_value_sparql(sv, cls, pre_slot_name, pre_equals, int(post_max_card)) + # Operators the compositional translation supports on a condition: those a + # value is tested against, and those stating whether the slot is present or + # some value satisfies a condition. + _VALUE_OPERATORS = frozenset( + {"equals_string", "equals_string_in", "minimum_value", "maximum_value", "range_expression"} + ) + _CONDITION_OPERATORS = _VALUE_OPERATORS | {"required", "value_presence", "has_member"} + + # The datatypes SPARQL compares numerically: the numeric types and the types + # derived from them (SPARQL 1.1 §17.1, + # ). + _SPARQL_NUMERIC_DATATYPES = frozenset( + str(XSD[name]) + for name in ( + "integer", + "decimal", + "float", + "double", + "nonPositiveInteger", + "negativeInteger", + "long", + "int", + "short", + "byte", + "nonNegativeInteger", + "unsignedLong", + "unsignedInt", + "unsignedShort", + "unsignedByte", + "positiveInteger", + ) + ) + def _compose_rule_sparql( + self, site: _RuleSite, pre: dict[str, SlotDefinition], post: dict[str, SlotDefinition] + ) -> str | None: + """Compose the query of a rule no named pattern matches from its slot conditions *pre* and *post*. + + The caller passes them after checking that the pre- and postconditions + set nothing else (:meth:`_rule_to_sparql`). Each precondition becomes + filters on ``$this``, and the single postcondition the union of the + ways to violate it, so the query selects the focus nodes that satisfy + every precondition and violate the postcondition (`SHACL §5.3.1 + `_), with + the offending value where there is one (`SHACL §5.3.2 + `_). + + Whether a condition requires its slot is decided by :meth:`_presence`. + Its value operators and ``range_expression`` apply to every value of + the slot: a slot constraint applies to all members of a collection + (``05validation.md``), as the JSON Schema generator applies it through + ``items``. ``has_member`` requires some value to satisfy its + condition. A condition may use :data:`_CONDITION_OPERATORS`; any other + operator skips the rule, as does an untranslatable value. + """ + if len(post) != 1: + self._skip_rule(site, f"{self._NO_PATTERN}, and its postconditions constrain more than one slot") + return None + lines: list[str] = [] + for index, (slot_name, condition) in enumerate(pre.items()): + slot = self._rule_condition_slot(site, slot_name) + if slot is None: + return None + filters = self._precondition_filters(site, slot, condition, f"?pre{index}") + if filters is None: + return None + lines.extend(filters) + ((post_name, post_cond),) = post.items() + post_slot = self._rule_condition_slot(site, post_name) + if post_slot is None: + return None + violations = self._postcondition_violations(site, post_slot, post_cond, bool(site.rule.open_world)) + if violations is None: + return None + if len(violations) == 1: + lines.extend(violations) + else: + lines.append(f"{{ {violations[0]} }}") + lines.extend(f"UNION {{ {violation} }}" for violation in violations[1:]) + body = "\n".join(f" {line}" for line in lines) + return f"SELECT DISTINCT $this (<{self._slot_iri(post_slot)}> AS ?path) ?value WHERE {{\n{body}\n}}" + + @staticmethod + def _presence(condition: SlotDefinition, default: PresenceEnum) -> PresenceEnum: + """Whether the rule *condition* requires its slot to be ``PRESENT``, ``ABSENT``, or neither (``UNCOMMITTED``). + + ``value_presence`` decides, then ``required``, then *default*, as the + JSON Schema generator decides whether a rule condition requires its + property: by default a precondition does, a postcondition unless the + rule is ``open_world`` ("the postconditions may be omitted in instance + data", metamodel), and an inner condition of a nested expression never. + """ + if condition.value_presence is not None: + return PresenceEnum(condition.value_presence) + if condition.required is not None: + return _PRESENT if condition.required else _UNCOMMITTED + return default + + def _precondition_filters( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, var: str + ) -> list[str] | None: + """The SPARQL filters that hold when *slot* of ``$this`` satisfies the precondition *condition*: + the slot is present (by default) or absent as :meth:`_presence` decides, and nothing violates it.""" + checked = self._condition_violations(site, slot, condition, "$this", var, _PRESENT, "precondition") + if checked is None: + return None + presence, violations = checked + value = f"$this <{self._slot_iri(slot)}> {var} ." + filters: list[str] = [] + if presence == _PRESENT: + filters.append(f"FILTER EXISTS {{ {value} }}") + elif presence == _ABSENT: + filters.append(f"FILTER NOT EXISTS {{ {value} }}") + return filters + [f"FILTER NOT EXISTS {{ {violation} }}" for violation in violations] + + def _postcondition_violations( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, open_world: bool + ) -> list[str] | None: + """The patterns by which ``$this`` violates the postcondition *condition* on *slot*. + + A pattern matching an offending value binds it to ``?value``. With + *open_world* "the postconditions may be omitted in instance data" + (metamodel ``open_world``), so the slot is not required by default. + """ + default = _UNCOMMITTED if open_world else _PRESENT + checked = self._condition_violations(site, slot, condition, "$this", "?value", default, "postcondition") + if checked is None: + return None + presence, violations = checked + presence_violation = self._presence_violation("$this", slot, "?value", presence) + violations = [presence_violation, *violations] if presence_violation else violations + if not violations: + self._skip_rule(site, f"its postcondition on slot {slot.name!r} constrains nothing") + return None + return violations + + def _presence_violation(self, subject: str, slot: SlotDefinition, var: str, presence: PresenceEnum) -> str | None: + """The pattern matching *subject* when its *slot* violates *presence*, binding *var* to a value that must + be absent; ``None`` when *presence* is ``UNCOMMITTED``.""" + value = f"{subject} <{self._slot_iri(slot)}> {var} ." + if presence == _PRESENT: + return f"FILTER NOT EXISTS {{ {value} }}" + if presence == _ABSENT: + return value return None - def _build_boolean_guard_sparql(self, sv, cls: ClassDefinition, flag_slot_name: str, value_slot_name: str) -> str: - """Build a SPARQL SELECT query for the boolean-guard pattern. + def _condition_violations( + self, + site: _RuleSite, + slot: SlotDefinition, + condition: SlotDefinition, + subject: str, + var: str, + default: PresenceEnum, + role: str, + ) -> tuple[PresenceEnum, list[str]] | None: + """The presence the *role* *condition* on *slot* of *subject* requires, and its other violations. + + A value bound to *var* violates the value operators or the + ``range_expression`` (:meth:`_value_violations`); ``has_member`` is + violated when the slot has values and none satisfies the member + condition. + """ + operators = self._set_operator_fields(condition) + if not operators <= self._CONDITION_OPERATORS: + self._skip_rule(site, self._unsupported(slot, operators, self._CONDITION_OPERATORS, role)) + return None + violations = self._value_violations(site, slot, condition, subject, var) + if violations is None: + return None + member_condition = condition.has_member + if member_condition is not None: + member_operators = self._set_operator_fields(member_condition) + if not member_operators or not member_operators <= self._VALUE_OPERATORS: + self._skip_rule(site, self._unsupported(slot, member_operators, self._VALUE_OPERATORS, "has_member")) + return None + member = f"{var}_member" + member_violations = self._value_violations(site, slot, member_condition, subject, member) + if member_violations is None: + return None + iri = self._slot_iri(slot) + satisfied = "".join(f" FILTER NOT EXISTS {{ {violation} }}" for violation in member_violations) + violations.append( + f"FILTER EXISTS {{ {subject} <{iri}> {var}_any . }} " + f"FILTER NOT EXISTS {{ {subject} <{iri}> {member} .{satisfied} }}" + ) + return self._presence(condition, default), violations - The query detects violations where the value property is present - but the boolean flag is absent or not ``true``. + def _value_violations( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, subject: str, var: str + ) -> list[str] | None: + """The patterns matching a value *var* of *slot* of *subject* that violates a value operator of *condition*. - Conforms to `SHACL §5.3.1 - `_: - ``$this`` is pre-bound to each focus node. + The value operators are tested on the value itself, a + ``range_expression`` on the value's own slots + (:meth:`_nested_violations`). + """ + value = f"{subject} <{self._slot_iri(slot)}> {var} ." + violations: list[str] = [] + if condition.range_expression is not None: + nested = self._nested_violations(site, slot, condition.range_expression, var) + if nested is None: + return None + violations.extend(f"{value} {violation}" for violation in nested) + test = self._value_test(site, slot, condition, var) + if test is None: + return None + if test: + violations.append(f"{value} FILTER ( !( {test} ) )") + return violations + + def _nested_violations( + self, site: _RuleSite, slot: SlotDefinition, expression: AnonymousClassExpression, node: str + ) -> list[str] | None: + """The patterns by which an object *node*, a value of *slot*, violates the class expression *expression*. + + *expression* must consist of slot conditions on the slot's range class, + resolved in that class's induced context. Each one holds for an absent + inner slot unless it requires the slot (:meth:`_presence`), as a slot + constraint applies to the values present and as the JSON Schema + generator reads a nested expression. On a reference that is not + inlined, the conditions apply to the referenced node's triples in the + data graph. + """ + sv = self.schemaview + operators = self._set_operator_fields(expression) + if operators != {"slot_conditions"}: + self._skip_rule( + site, self._unsupported(slot, operators, frozenset({"slot_conditions"}), "range_expression") + ) + return None + range_name = self._slot_range(slot) + if range_name not in sv.all_classes(): + self._skip_rule(site, f"its range_expression is on slot {slot.name!r}, whose range is not a class") + return None + range_class = sv.get_class(range_name) + violations: list[str] = [] + for index, (inner_name, condition) in enumerate(expression.slot_conditions.items()): + inner = self._rule_condition_slot(site, inner_name, range_class) + if inner is None: + return None + var = f"{node}_{index}" + checked = self._condition_violations(site, inner, condition, node, var, _UNCOMMITTED, "inner condition") + if checked is None: + return None + presence, inner_violations = checked + presence_violation = self._presence_violation(node, inner, var, presence) + if presence_violation: + violations.append(presence_violation) + violations.extend(inner_violations) + return violations + + def _value_test(self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, var: str) -> str | None: + """The SPARQL test that a value *var* of *slot* satisfies the value operators of *condition*. + + Returns ``""`` when *condition* sets no value operator, and ``None`` + (rule skipped) when a value cannot be translated. ``equals_string`` + and ``equals_string_in`` compare as :meth:`_value_terms` renders them. + ``minimum_value`` / ``maximum_value`` are inclusive bounds (metamodel), + compared numerically, so the slot's datatype must be one SPARQL + compares as a number (:data:`_SPARQL_NUMERIC_DATATYPES`). Comparing a + value of another type with a number is a type error (`SPARQL 1.1 §17.3 + `_), which some + engines resolve as an ordering anyway, so `isNumeric + `_ guards the + comparison; an engine that takes an ill-typed literal such as + ``"abc"^^xsd:integer`` for a number still compares it. Every part is + false, rather than an error, for a value it cannot compare (see + :meth:`_sparql_is_one_of`). + """ + parts: list[str] = [] + for values in ( + [condition.equals_string] if condition.equals_string is not None else None, + list(condition.equals_string_in) if condition.equals_string_in else None, + ): + if values is None: + continue + terms = self._value_terms(site, slot, values) + if terms is None: + return None + parts.append(self._sparql_is_one_of(var, terms)) + bounds = { + name: (bound, operator) + for name, bound, operator in ( + ("minimum_value", condition.minimum_value, ">="), + ("maximum_value", condition.maximum_value, "<="), + ) + if bound is not None + } + r = self._slot_range(slot) + if bounds and self._type_uri(r) not in self._SPARQL_NUMERIC_DATATYPES: + self._skip_rule( + site, + f"its {' and '.join(bounds)} on slot {slot.name!r} {'need' if len(bounds) > 1 else 'needs'} a type " + "with a numeric datatype, and " + (f"the slot's range is {r!r}" if r else "the slot has no range"), + ) + return None + comparisons: list[str] = [] + for bound, operator in bounds.values(): + number = self._sparql_number(bound) + if number is None: + self._skip_rule(site, f"its bound {bound!r} on slot {slot.name!r} is not a finite number") + return None + comparisons.append(f"{var} {operator} {number}") + if comparisons: + parts.append(f"COALESCE( {' && '.join([f'isNumeric( {var} )', *comparisons])}, false )") + return " && ".join(parts) + + @staticmethod + def _sparql_number(value: object) -> str | None: + """Render a ``minimum_value`` / ``maximum_value`` bound as a SPARQL numeric literal, or ``None``. + + The metamodel range of both is ``Anything``, so strings, dates, + booleans and ``.nan`` / ``.inf`` reach the generator unchanged. + Interpolated raw, ``"abc"`` makes the query unparsable and a date such + as ``2020-01-01`` parses as arithmetic; only ``int`` and finite + ``float`` values are rendered, and their ``str`` is a plain numeric + token that SPARQL compares with numeric type promotion. """ - flag_uri = self._slot_uri(sv, flag_slot_name, cls) - value_uri = self._slot_uri(sv, value_slot_name, cls) + if isinstance(value, bool) or not isinstance(value, int | float): + return None + if isinstance(value, float) and not math.isfinite(value): + return None + return str(value) + @classmethod + def _unsupported(cls, slot: SlotDefinition, operators: set[str], supported: frozenset[str], role: str) -> str: + """The reason a condition with *operators* in *role* on *slot* is not translated.""" + used = ", ".join(sorted(operators)) or "no operator" return ( - f"SELECT $this WHERE {{\n" - f" OPTIONAL {{ $this <{flag_uri}> ?flag . }}\n" - f" OPTIONAL {{ $this <{value_uri}> ?value . }}\n" - f" FILTER (\n" - f' ( !BOUND(?flag) || str(?flag) != "true" ) &&\n' - f" BOUND(?value)\n" - f" )\n" - f"}}" + f"{cls._NO_PATTERN}; its {role} on slot {slot.name!r} uses {used}, where the compositional " + f"translation supports only {', '.join(sorted(supported))}" ) - def _build_exclusive_value_sparql( - self, - sv, - cls: ClassDefinition, - slot_name: str, - value_name: str, - max_card: int, - ) -> str | None: - """Build a SPARQL SELECT query for the exclusive-value pattern. + def _rule_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: + """Resolve a rule condition's slot key to the slot it names, or ``None`` + when no such slot exists. - Detects violations where a specific value is present in a multivalued - slot but the total number of values exceeds *max_card*. + Resolution order mirrors ``sh:path`` in the main slot loop: the induced + (class-specific) slot when the key names one of the class's slots, then + the underscored alias form (a rule key ``my_slot`` for a slot named + ``my slot`` — SchemaView normalises names the same way elsewhere), then + the base slot. + """ + sv = self.schemaview + class_slot_names = sv.class_slots(cls.name) + if slot_name in class_slot_names: + return sv.induced_slot(slot_name, cls.name) + canonical = next((s for s in class_slot_names if underscore(s) == underscore(slot_name)), None) + if canonical is not None: + return sv.induced_slot(canonical, cls.name) + return sv.get_slot(slot_name) + + def _rule_condition_slot( + self, site: _RuleSite, slot_name: str, cls: ClassDefinition | None = None + ) -> SlotDefinition | None: + """The slot a condition of the rule at *site* names, or ``None`` (rule skipped) when it cannot be queried. + + The slot is resolved in the context of ``site.cls``, or of *cls* for an + inner condition on the range class of a nested expression. + + An unknown name would make the query use a predicate no shape or data + uses. An identifier slot is the node's IRI, not a property arc (the + main slot loop does not require it either), so a condition on it can + never match. + """ + slot = self._rule_slot(cls if cls is not None else site.cls, slot_name) + if slot is None: + self._skip_rule(site, f"its condition names {slot_name!r}, which is not a slot") + return None + if slot.identifier: + self._skip_rule( + site, f"its condition is on the identifier slot {slot_name!r}, which is the node IRI, not a property" + ) + return None + return slot + + def _slot_range(self, slot: SlotDefinition) -> ElementName | None: + """The range of *slot*, defaulting to the schema's ``default_range`` as an induced slot does.""" + return slot.range or self.schemaview.schema.default_range + + def _slot_iri(self, slot: SlotDefinition) -> str: + """The full IRI of *slot*, exactly as ``sh:path`` in the main slot loop renders it. + + An induced slot carries its ``slot_usage`` overrides, so an overridden + ``slot_uri`` yields the same IRI as ``sh:path``; otherwise the query + would use a property the data never uses and never fire. + """ + sv = self.schemaview + if slot.name in sv.element_by_schema_map(): + return sv.get_uri(slot, expand=True) + return sv.expand_curie(f"{sv.schema.default_prefix}:{underscore(slot.name)}") + + def _value_terms(self, site: _RuleSite, slot: SlotDefinition, values: list[str]) -> list[str] | None: + """The SPARQL terms of the equals_string(_in) *values* on *slot*, or ``None`` (rule skipped). + + The metamodel defines both operators for slots of range ``string``. + Enums, whose values are strings in instance data, and types with + datatype ``xsd:string`` are treated alike (:meth:`_string_value_term`). + A slot whose range is ``xsd:boolean``-typed holds booleans, as the + boolean guard compares them: each value must be a lexical form of + ``xsd:boolean`` and becomes the boolean it denotes, compared by value. + On any other range the RDF data holds typed literals + (``"3"^^xsd:integer``) or IRIs that no string equals, so the + constraint would report conforming data or never fire. + """ + r = self._slot_range(slot) + if self._is_string_range(r): + return [self._string_value_term(site, slot, value) for value in values] + if self._type_uri(r) == str(XSD.boolean): + terms = [self._XSD_BOOLEAN_LEXICAL.get(value) for value in values] + if None not in terms: + return terms + self._skip_rule( + site, + f"equals_string(_in) on the boolean slot {slot.name!r} with {values!r}, " + "which are not all xsd:boolean lexical forms (true, false, 1, 0)", + ) + return None + self._skip_rule( + site, + f"equals_string(_in) on slot {slot.name!r}, whose range {r!r} is neither an enum " + "nor a type with datatype xsd:string or xsd:boolean", + ) + return None - For the common case ``max_card == 1``, the query checks whether the - exclusive value coexists with any other value (simple existence test). - For ``max_card > 1``, a subquery counts all values and checks against - the limit. + def _string_value_term(self, site: _RuleSite, slot: SlotDefinition, value: str) -> str: + """The SPARQL term of the ``equals_string`` *value* of string-valued *slot*. - The exclusive value is resolved to its full IRI via the slot's enum - ``meaning`` field. If the slot is not an enum or the value has no - ``meaning``, the value is compared as a plain literal. + A permissible value of an enum range is rendered as :meth:`_add_enum` + renders it, as the IRI of its ``meaning`` where it has one; anything + else is a string literal. A value that is not a permissible value of + an enum with static permissible values is reported: no valid value of + the slot can equal it. + """ + sv = self.schemaview + r = self._slot_range(slot) + if r in sv.all_enums(): + permissible_values = sv.get_enum(r).permissible_values + pv = permissible_values.get(value) + if pv is not None and pv.meaning: + return f"<{sv.expand_curie(pv.meaning)}>" + if pv is None and permissible_values: + self._warn_rule( + site, + f"it compares slot {slot.name!r} with {value!r}, which is not a permissible value of enum {r!r}", + ) + return self._sparql_string_literal(value) + + @staticmethod + def _sparql_string_literal(value: str) -> str: + """Render *value* as a SPARQL expression for that string. + + The characters the grammar forbids raw are escaped (`SPARQL 1.1 §19.7 + `_), so a value + with a double quote, backslash or newline neither breaks the + ``sh:select`` query nor injects into it. Codepoint escapes are replaced + before parsing (`§19.2 `_), + so an escaped backslash followed by ``u`` / ``U`` would be read as one: + the literal is split there and rejoined with ``CONCAT``. + """ + escaped = ( + str(value) + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + parts = re.split(r"(?<=\\)(?=[uU])", escaped) + if len(parts) == 1: + return f'"{escaped}"' + return "CONCAT(" + ", ".join(f'"{part}"' for part in parts) + ")" + + @staticmethod + def _sparql_is_one_of(var: str, terms: list[str]) -> str: + """A SPARQL expression that is true when *var* equals one of *terms*, and false otherwise. + + The test is spelled out as ``var = t1 || var = t2 || ...``, which is how + `SPARQL 1.1 §17.4.1.9 `_ + defines ``IN``. The ``=`` operator compares values, so a plain literal + also matches its RDF 1.1-identical ``xsd:string`` form (`RDF 1.1 + Concepts §3.3 `_), + which JSON-LD produces under ``"@type": "xsd:string"`` coercion; some + engines, rdflib among them, match ``IN`` and triple-pattern constants by + term identity and miss that form. ``COALESCE`` (§17.4.1.3) turns the + type error that ``RDFterm-equal`` (§17.4.1.7) raises for incomparable + literals into false: such a value equals none of *terms*, and an + unguarded error would drop the row and hide the violation. An unbound + *var* is false as well. + """ + disjunction = " || ".join(f"{var} = {term}" for term in terms) + return f"COALESCE( {disjunction}, false )" + + def _build_presence_implies_value_sparql( + self, guard: SlotDefinition, target: SlotDefinition, terms: list[str], open_world: bool + ) -> str: + """Build the SPARQL SELECT query of the presence-implies-value pattern. + + A focus node violates the rule when the *guard* slot is present and the + *target* slot is absent or holds a value that equals none of the SPARQL + *terms* (see :meth:`_sparql_is_one_of`). The boolean guard is this + pattern with the boolean ``true`` as the only term. With *open_world*, + "the postconditions may be omitted in instance data" (metamodel + ``open_world``), so an absent target is no violation. Conforms to `SHACL §5.3.1 - `_: - ``$this`` is pre-bound to each focus node. + `_ (``$this`` + is pre-bound to each focus node) and projects ``?path`` and ``?value`` + as result variables (`§5.3.2 + `_), so each + result names the target property and the offending value. """ - slot_uri = self._slot_uri(sv, slot_name, cls) - value_ref = self._resolve_enum_value_ref(sv, slot_name, value_name) + guard_iri = self._slot_iri(guard) + target_iri = self._slot_iri(target) + is_allowed = self._sparql_is_one_of("?value", terms) + if open_world: + target_pattern = f"$this <{target_iri}> ?value ." + violation = f"!{is_allowed}" + else: + target_pattern = f"OPTIONAL {{ $this <{target_iri}> ?value . }}" + violation = f"!BOUND(?value) || !{is_allowed}" + return ( + f"SELECT DISTINCT $this (<{target_iri}> AS ?path) ?value WHERE {{\n" + f" $this <{guard_iri}> ?guard .\n" + f" {target_pattern}\n" + f" FILTER ( {violation} )\n" + f"}}" + ) + + def _build_exclusive_value_sparql(self, slot: SlotDefinition, term: str, max_card: int) -> str: + """Build the SPARQL SELECT query of the exclusive-value pattern. + A focus node violates the rule when *slot* holds a value equal to the + SPARQL *term* (see :meth:`_sparql_is_one_of`) and more than *max_card* + values in total. For ``max_card == 1`` the query reports each other + value that coexists with the exclusive one. For ``max_card > 1`` a + subquery counts all values, testing ``HAVING`` on the ``COUNT`` + aggregate itself: expressions projected by ``SELECT`` are not visible + to ``HAVING`` (`SPARQL 1.1 §18.2.4.2 + `_), so a + projected alias would be unbound there and the constraint never fire. + + Conforms to `SHACL §5.3.1 + `_ and + projects ``?path`` (and, for ``max_card == 1``, the coexisting value as + ``?value``) as result variables (`§5.3.2 + `_). + """ + iri = self._slot_iri(slot) + is_exclusive = self._sparql_is_one_of("?exclusive", [term]) if max_card == 1: + is_other_exclusive = self._sparql_is_one_of("?other", [term]) return ( - f"SELECT $this WHERE {{\n" - f" $this <{slot_uri}> {value_ref} .\n" - f" $this <{slot_uri}> ?other .\n" - f" FILTER (?other != {value_ref})\n" + f"SELECT DISTINCT $this (<{iri}> AS ?path) (?other AS ?value) WHERE {{\n" + f" $this <{iri}> ?exclusive .\n" + f" $this <{iri}> ?other .\n" + f" FILTER ( {is_exclusive} && !{is_other_exclusive} )\n" f"}}" ) - return ( - f"SELECT $this WHERE {{\n" - f" $this <{slot_uri}> {value_ref} .\n" + f"SELECT DISTINCT $this (<{iri}> AS ?path) WHERE {{\n" + f" $this <{iri}> ?exclusive .\n" + f" FILTER ( {is_exclusive} )\n" f" {{\n" - f" SELECT $this (COUNT(?val) AS ?count)\n" - f" WHERE {{ $this <{slot_uri}> ?val . }}\n" + f" SELECT $this\n" + f" WHERE {{ $this <{iri}> ?item . }}\n" f" GROUP BY $this\n" - f" HAVING (?count > {max_card})\n" + f" HAVING ( COUNT(?item) > {max_card} )\n" f" }}\n" f"}}" ) - def _resolve_enum_value_ref(self, sv, slot_name: str, value_name: str) -> str: - """Resolve an enum value name to a SPARQL term (IRI or literal). - - Looks up the slot's range as an enum, finds the permissible value - matching *value_name*, and returns its ``meaning`` as a full IRI - wrapped in angle brackets. Falls back to a quoted literal if the - slot is not an enum or the value lacks a ``meaning``. - """ - slot = sv.get_slot(slot_name) - if slot: - range_name = slot.range - if range_name and range_name in sv.all_enums(): - enum = sv.get_enum(range_name) - pv = enum.permissible_values.get(value_name) - if pv and pv.meaning: - iri = sv.expand_curie(pv.meaning) - return f"<{iri}>" - return f'"{value_name}"' - - def _slot_uri(self, sv, slot_name: str, cls: ClassDefinition) -> str: - """Resolve a slot name to a full IRI string for use in SPARQL queries. - - Mirrors the resolution logic used for ``sh:path`` in the main slot loop: - prefer ``sv.get_uri()`` for slots registered in the schema map, fall - back to ``default_prefix:underscored_name``. - """ - slot = sv.get_slot(slot_name) - if slot and slot_name in sv.element_by_schema_map(): - return sv.get_uri(slot, expand=True) - pfx = sv.schema.default_prefix - return sv.expand_curie(f"{pfx}:{underscore(slot_name)}") - def _add_class(self, func: Callable, r: ElementName) -> None: """Add a class/shape constraint for range class *r*. @@ -671,6 +1796,25 @@ def _add_class(self, func: Callable, r: ElementName) -> None: range_ref += self.suffix func(SH["node"], URIRef(range_ref)) + def _add_range(self, g: Graph, func: Callable, r: ElementName) -> None: + """Add the value-type constraint for range *r*: a class, type, enum or built-in datatype.""" + sv = self.schemaview + if r in sv.all_classes(): + cls_def = sv.get_class(r) + is_any = cls_def and getattr(cls_def, "class_uri", None) == "linkml:Any" + self._add_class(func, r) + if not is_any: + if sv.get_identifier_slot(r) is not None: + func(SH.nodeKind, SH.IRI) + else: + func(SH.nodeKind, SH.BlankNodeOrIRI) + elif r in sv.all_types(): + self._add_type(func, r) + elif r in sv.all_enums(): + self._add_enum(g, func, r) + else: + add_simple_data_type(func, r) + def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None: sv = self.schemaview enum = sv.get_enum(r) @@ -685,37 +1829,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", - } - ) - 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) + # Resolve vocabulary IRIs before selecting RDF node kinds. The ShEx + # vocabulary distinguishes IRI-only and non-literal (IRI or blank) nodes. + node_kind = None + if expanded == str(SHEX.iri): + node_kind = SH.IRI + elif expanded == str(SHEX.nonLiteral): + node_kind = SH.BlankNodeOrIRI + elif expanded == str(XSD.anyURI) and is_xsd_anyuri_range(sv, r): + node_kind = SH.IRI + if node_kind is not None: + func(SH.nodeKind, node_kind) elif type_uri: func(SH.nodeKind, SH.Literal) func(SH.datatype, URIRef(sv.get_uri(rt, expand=True))) @@ -787,6 +1917,10 @@ def _add_annotations(self, func: Callable, item) -> None: if type(annotations) is JsonObj: annotations = as_dict(annotations) for a in annotations.values(): + declared = declared_annotation(sv, item, a["tag"], a["value"], self._resolve_language(item)) + if declared is not None: + func(*declared) + continue # If ':' is in the tag, treat it as a CURIE, otherwise string Literal if ":" in a["tag"]: N_predicate = URIRef(sv.expand_curie(a["tag"])) @@ -924,11 +2058,21 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: show_default=True, help=( "Emit sh:sparql constraints from LinkML rules: blocks. " - "When enabled (default), recognised rule patterns (e.g. boolean-guard) " - "are translated into SHACL-SPARQL constraints on the corresponding " + "When enabled (default), recognised rule patterns (boolean-guard, " + "presence-implies-value, exclusive-value) are translated into " + "SHACL-SPARQL constraints on the corresponding " "sh:NodeShape. Use --no-emit-rules to suppress rule generation." ), ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **args): """Generate SHACL turtle from a LinkML model""" diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py index 40a93ffbc9..12c9ac5dc7 100644 --- a/packages/linkml/src/linkml/generators/shexgen.py +++ b/packages/linkml/src/linkml/generators/shexgen.py @@ -2,23 +2,25 @@ import os import urllib.parse as urlparse +from copy import deepcopy from dataclasses import dataclass, field 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 from ShExJSG import ShExC from ShExJSG.SchemaWithContext import Schema from ShExJSG.ShExJ import IRIREF, EachOf, NodeConstraint, Shape, ShapeOr, TripleConstraint from linkml import METAMODEL_NAMESPACE, METAMODEL_NAMESPACE_NAME from linkml._version import __version__ -from linkml.generators.common.subproperty import get_subproperty_values +from linkml.generators.common.subproperty import get_subproperty_values, is_xsd_anyuri_range from linkml.utils.generator import Generator, shared_arguments from linkml_runtime.linkml_model.meta import ( ClassDefinition, ElementName, EnumDefinition, + Prefix, SlotDefinition, SlotDefinitionName, TypeDefinition, @@ -27,6 +29,7 @@ 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.schemaview import SchemaView @dataclass @@ -40,6 +43,9 @@ class ShExGenerator(Generator): uses_schemaloader = True # ObjectVars + diff_stable: bool = False + """Reduce blank-node label churn in RDF output. See :ref:`rdf-in-version-control`.""" + shex: Schema = field(default_factory=lambda: Schema()) # ShEx Schema being generated shapes: list = field(default_factory=lambda: []) shape: Shape | None = None # Current shape being defined @@ -67,12 +73,20 @@ def visit_schema(self, **_): # Adjust the schema context to include the base model URI context = self.shex["@context"] self.shex["@context"] = [context, {"@base": self.namespaces._base}] + # SchemaLoader has already resolved imports. Inspect a copy of that + # effective schema without reopening source files or changing provenance. + type_schema = deepcopy(self.schema) + type_schema.imports = [] + for prefix, namespace in self.namespaces.items(): + if not prefix.startswith("@"): + type_schema.prefixes[prefix] = Prefix(prefix, str(namespace)) + type_view = SchemaView(type_schema) # Emit all of the type definitions 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): + if typ_type_uri == SHEX.iri or is_xsd_anyuri_range(type_view, typ.name): self.shapes.append(NodeConstraint(id=model_uri, nodeKind="iri")) elif typ_type_uri == SHEX.nonLiteral: self.shapes.append(NodeConstraint(id=model_uri, nodeKind="nonliteral")) @@ -177,7 +191,7 @@ def end_schema(self, output: str | None = None, **_) -> str: g = Graph() g.parse(data=shex, format="json-ld", version="1.1") g.bind("owl", OWL) - shex = canonicalize_rdf_graph(g, output_format="turtle") + shex = canonicalize_rdf_graph(g, output_format="turtle", diff_stable=self.diff_stable) elif self.format == "shex": g = Graph() self.namespaces.load_graph(g) @@ -258,6 +272,15 @@ def _get_subproperty_values(self, slot: SlotDefinition) -> list: help="If --expand-subproperty-of (default), slots with subproperty_of will generate NodeConstraint " "values containing all slot descendants. Use --no-expand-subproperty-of to disable this behavior.", ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Reduce blank-node label churn across edits (--format rdf). See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **args): """Generate a ShEx Schema for a LinkML model""" diff --git a/packages/linkml/src/linkml/utils/generator.py b/packages/linkml/src/linkml/utils/generator.py index dc338941e4..31e3bfa168 100644 --- a/packages/linkml/src/linkml/utils/generator.py +++ b/packages/linkml/src/linkml/utils/generator.py @@ -20,17 +20,18 @@ import os import re import sys +import types import warnings from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path -from typing import IO, Any, ClassVar, TextIO, Union, cast +from typing import IO, TYPE_CHECKING, Any, ClassVar, TextIO, Union, cast import click import yaml -from click import Argument, Command, Option +from click import Argument, Command, Option, ParameterSource from linkml import LOCAL_METAMODEL_YAML_FILE from linkml.cli.logging import DEFAULT_LOG_LEVEL_INT, log_level_option @@ -60,6 +61,9 @@ from linkml_runtime.utils.formatutils import camelcase, underscore from linkml_runtime.utils.namespaces import Namespaces +if TYPE_CHECKING: + from rdflib import Graph + logger = logging.getLogger(__name__) @@ -80,6 +84,154 @@ def _resolved_metamodel(mergeimports): return metamodel +def well_known_prefix_map() -> dict[str, str]: + """Return a mapping from namespace URI to standard prefix name. + + Primary source: the ``linked_data`` context from `prefixmaps + `_ — the canonical curated + registry maintained by the LinkML team. This context provides + correct, community-consensus prefix names (e.g. ``sh`` not ``shacl``, + ``schema`` not ``sdo``). + + Secondary source: the ``merged`` context from prefixmaps, which + combines prefix.cc, bioregistry, and other sources for broad coverage. + + A small ``_PREFIX_OVERRIDES`` map corrects the few cases where the + merged context disagrees with rdflib/W3C canonical names. + + Both ``http`` and ``https`` variants of schema.org and wgs84 are + included because the linkml-runtime historically binds the HTTP form + while rdflib (and the W3C) prefer HTTPS. + + .. note:: + Requires ``prefixmaps >= 0.2.7``. For entries added in + linkml/prefixmaps#81 (W3C/OGC standard prefixes), pin to + ``prefixmaps @ git+https://github.com/linkml/prefixmaps@75435150`` + until v0.2.8 is released. + """ + return dict(_cached_well_known_prefix_map()) + + +@lru_cache(maxsize=1) +def _cached_well_known_prefix_map() -> dict[str, str]: + """Internal cached builder for well_known_prefix_map().""" + from prefixmaps import load_context + + # Layer 1: merged context (broad coverage, first-seen-wins for duplicates). + merged = load_context("merged") + ns_to_prefix: dict[str, str] = {} + for rec in merged.prefix_expansions: + if rec.namespace not in ns_to_prefix: + ns_to_prefix[rec.namespace] = rec.prefix + + # Layer 2: linked_data context (curated, correct names) overrides merged. + ld = load_context("linked_data") + for rec in ld.prefix_expansions: + ns_to_prefix[rec.namespace] = rec.prefix + + # Layer 3: overrides for the few cases where merged/linked_data disagrees + # with the rdflib/W3C canonical forms used by the RDF community. + for ns, pfx in _PREFIX_OVERRIDES.items(): + ns_to_prefix[ns] = pfx + + # Ensure both HTTP/HTTPS schema.org variants resolve to 'schema'. + ns_to_prefix.setdefault("https://schema.org/", "schema") + ns_to_prefix["http://schema.org/"] = "schema" + + # Ensure both HTTP/HTTPS wgs84 variants resolve to 'wgs'. + ns_to_prefix.setdefault("https://www.w3.org/2003/01/geo/wgs84_pos#", "wgs") + + return ns_to_prefix + + +# Overrides: corrections where prefixmaps merged context uses non-standard names +# that differ from rdflib 7.x / W3C canonical forms. +_PREFIX_OVERRIDES: types.MappingProxyType[str, str] = types.MappingProxyType( + { + # merged gives 'geosparql', rdflib/W3C uses 'geo' + "http://www.opengis.net/ont/geosparql#": "geo", + # merged gives 'sc', rdflib/W3C uses 'schema' + "https://schema.org/": "schema", + # merged gives 'WGS84', rdflib uses 'wgs' + "https://www.w3.org/2003/01/geo/wgs84_pos#": "wgs", + "http://www.w3.org/2003/01/geo/wgs84_pos#": "wgs", + } +) + + +def normalize_graph_prefixes(graph: "Graph", schema_prefixes: dict[str, str]) -> None: + """Normalise non-standard prefix aliases in an rdflib Graph. + + For each prefix bound in *schema_prefixes* (mapping prefix name → + namespace URI), check whether ``well_known_prefix_map()`` knows a + standard name for that URI. If the standard name differs from the + schema-declared name, rebind the namespace to the standard name. + + This is the **shared implementation** used by OWL, SHACL, and (via a + different code-path) JSON-LD context generators so that all serialisation + formats agree on prefix names when ``--normalize-prefixes`` is active. + + :param graph: rdflib Graph whose namespace bindings should be adjusted. + :param schema_prefixes: mapping of prefix name → namespace URI string, + typically from ``schema.prefixes``. + """ + from rdflib import Namespace + + wk = well_known_prefix_map() + + # Phase 1: normalise schema-declared prefixes. + for old_pfx, ns_uri in schema_prefixes.items(): + ns_str = str(ns_uri) + std_pfx = wk.get(ns_str) + if not std_pfx or std_pfx == old_pfx: + continue + # Collision: the user explicitly declared std_pfx for a different + # namespace — do not clobber their binding. + if std_pfx in schema_prefixes and schema_prefixes[std_pfx] != ns_str: + logger.warning( + "Prefix collision: cannot rename '%s' to '%s' because '%s' is already " + "declared for <%s>; skipping normalisation for <%s>", + old_pfx, + std_pfx, + std_pfx, + schema_prefixes[std_pfx], + ns_str, + ) + continue + # Rebind: remove old prefix, add standard prefix. + # ``replace=True`` forces the new prefix even if the prefix name + # is already bound to a different namespace. + graph.bind(std_pfx, Namespace(ns_str), override=True, replace=True) + + # Phase 2: normalise runtime-injected bindings (e.g. metamodel defaults). + # The linkml-runtime / rdflib may inject well-known namespaces under + # non-standard prefix names. After Phase 1 rebinds schema-declared + # prefixes, orphaned runtime bindings can appear as ``schema1``, ``dc0``, + # etc. Scan the graph's current bindings and fix any that map to a + # well-known namespace under a non-standard name, provided the standard + # name isn't already claimed by the user for a different namespace. + # + # Guard: if Phase 1 already bound std_pfx to a different URI (e.g. + # ``schema`` → ``https://schema.org/``), do not clobber it with the + # HTTP variant (``http://schema.org/``). Build a snapshot of the + # current bindings after Phase 1 to detect this. + current_bindings = {str(p): str(n) for p, n in graph.namespaces()} + for pfx, ns in list(graph.namespaces()): + pfx_str, ns_str = str(pfx), str(ns) + std_pfx = wk.get(ns_str) + if not std_pfx or std_pfx == pfx_str: + continue + # Same collision check as Phase 1: respect user-declared prefixes. + if std_pfx in schema_prefixes and schema_prefixes[std_pfx] != ns_str: + continue + # Guard: if std_pfx is already bound to a different (correct) URI + # by Phase 1, do not overwrite it. This prevents the HTTP variant + # of schema.org from clobbering the HTTPS binding. + if std_pfx in current_bindings and current_bindings[std_pfx] != ns_str: + continue + graph.bind(std_pfx, Namespace(ns_str), override=True, replace=True) + + @dataclass class Generator(metaclass=abc.ABCMeta): """ @@ -99,6 +251,9 @@ class Generator(metaclass=abc.ABCMeta): generatorversion: ClassVar[str] = None # Generator version identifier """Version of the generator. Consider deprecating and instead use overall linkml version""" + config_section_name: ClassVar[str] = None + """Section this generator reads from a ``--config-file`` (``generator_args.``).""" + uses_schemaloader: ClassVar[bool] = True """Old-style generator that uses the SchemaLoader and visitor pattern""" @@ -246,6 +401,12 @@ def namespaces(self, value: Namespaces | None) -> None: stacktrace: bool = False """True means print stack trace, false just error message""" + normalize_prefixes: bool = False + """True means normalise non-standard prefix aliases to well-known names + from the ``prefixmaps`` package (linked_data + merged contexts, with + overrides for rdflib/W3C canonical forms). E.g. ``sdo`` → ``schema`` + for ``https://schema.org/``.""" + include: str | Path | SchemaDefinition | None = None """If set, include extra schema outside of the imports mechanism""" @@ -1099,6 +1260,16 @@ def decorator(f: Command) -> Command: callback=stacktrace_callback, ) ) + f.params.append( + Option( + ("--normalize-prefixes/--no-normalize-prefixes",), + default=False, + show_default=True, + help="Normalise non-standard prefix aliases to rdflib's curated default names " + "(e.g. sdo → schema for https://schema.org/). " + "Supported by OWL, SHACL, and JSON-LD Context generators.", + ) + ) return f @@ -1188,3 +1359,47 @@ def read_generator_config(config_file: IO[bytes] | None, generator_name: str) -> return config_mapping(generator_args.get(generator_name), f"'generator_args.{generator_name}'", source) except ValueError as e: raise click.UsageError(str(e)) from e + + +# Options whose click callbacks fire at parse time (e.g. configuring logging); a +# config-file value would be recorded but never trigger the callback, so these are +# honored only from the command line, never overlaid from a config file. +_CONFIG_OVERLAY_SKIP = frozenset({"yamlfile", "schema", "verbose", "log_level", "stacktrace", "config_file"}) + + +def apply_config_defaults(ctx: click.Context, config: Mapping[str, Any], args: dict[str, Any]) -> dict[str, Any]: + """Overlay ``--config-file`` values onto CLI args the user did not set. + + Precedence is command line/env over config over option defaults: a value is + taken from ``config`` only when its option was left at the default. Values are + converted by their option's click type, so a config file and a command line + reach the generator identically. Keys that are not options of this command are + reported per-key (a typo in the config file) rather than silently dropped. + + :param ctx: The active click context, used to tell which options the user set. + :param config: One generator's settings, e.g. from :func:`read_generator_config`. + :param args: The keyword args forwarded to the generator; updated in place. + :return: ``args``, updated in place. + :raises click.BadParameter: If a value is not valid for its option's type. + """ + if not config: + return args + # expose_value=False params (--version, --help) never reach the callback, so a + # config key naming one is a typo, not something to overlay + params = {param.name: param for param in ctx.command.params if param.expose_value} + for key, value in config.items(): + param = params.get(key) + if param is None: + logger.warning(f"--config-file: ignoring unknown key {key!r}") + continue + if key in _CONFIG_OVERLAY_SKIP: + continue + # overlay only when the option was left at its default; CLI/env values already win + if ctx.get_parameter_source(key) in (ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP): + # a YAML scalar stands in for a one-element list on a repeatable option; + # click would otherwise iterate a string per character + if param.multiple and not isinstance(value, list | tuple): + value = [value] + # convert as click would, so e.g. `template_dir: /tmp` arrives as a Path + args[key] = param.type_cast_value(ctx, value) + return args diff --git a/packages/linkml/src/linkml/utils/schemaloader.py b/packages/linkml/src/linkml/utils/schemaloader.py index 846e919c0e..3bb0e0aee9 100644 --- a/packages/linkml/src/linkml/utils/schemaloader.py +++ b/packages/linkml/src/linkml/utils/schemaloader.py @@ -299,7 +299,6 @@ def resolve(self) -> SchemaDefinition: self.schema_defaults.get(cls.from_schema, suffixed_cls_schema), camelcase(cls.name), ) - cls.exact_mappings.insert(0, cls.class_uri) # Get the inverse ducks all in a row before we start filling other stuff in for slot in self.schema.slots.values(): diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index 5f69503d48..20db1deecc 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", + "diffable-rdf>=0.4.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] @@ -62,6 +63,7 @@ dev = [ linkml-normalize = "linkml_runtime.processing.referencevalidator:cli" [project.optional-dependencies] +diff-stable = ["diffable-rdf>=0.4.0"] dev = [ "coverage", "requests-cache>=1.3.3", diff --git a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py index 177186051a..8c3da30dca 100644 --- a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py +++ b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py @@ -5,7 +5,7 @@ from curies import Converter from pydantic import BaseModel from rdflib import XSD, Graph, URIRef -from rdflib.namespace import RDF +from rdflib.namespace import RDF, RDFS from rdflib.term import BNode, Literal, Node from linkml_runtime.dumpers.dumper_root import Dumper @@ -101,17 +101,19 @@ def inject_triples( else: return Literal(element.text) if target_type in schemaview.all_types(): - t = schemaview.get_type(target_type) + t = schemaview.induced_type(target_type) dt_uri = t.uri if dt_uri: - if dt_uri in ("rdfs:Resource", "xsd:anyURI"): + if "xsd" not in namespaces: + namespaces["xsd"] = XSD + datatype = namespaces.uri_for(dt_uri) + uri_ancestry = any(name in ("uri", "uriorcurie") for name in schemaview.type_ancestors(target_type)) + if datatype == RDFS.Resource or (datatype == XSD.anyURI and uri_ancestry): return URIRef(schemaview.expand_curie(element)) - elif dt_uri == "xsd:string": + elif datatype == XSD.string: return Literal(element) else: - if "xsd" not in namespaces: - namespaces["xsd"] = XSD - return Literal(element, datatype=namespaces.uri_for(dt_uri)) + return Literal(element, datatype=datatype) else: logger.warning(f"No datatype specified for : {t.name}, using plain Literal") return Literal(element) diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index 31c5580348..b7380d0219 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -1,48 +1,40 @@ -"""Deterministic RDF serialization via pyoxigraph RDFC-1.0 canonicalization. - -This module provides a function to canonicalize an rdflib Graph using -pyoxigraph's RDFC-1.0 implementation, producing deterministic output -with stable blank node labels and sorted triples. - -**Known limitations:** - -1. **xsd:string normalization**: pyoxigraph follows RDF 1.1, where plain - string literals and ``"text"^^xsd:string`` are identical. The output - will never contain explicit ``^^xsd:string`` annotations. Code that - re-parses the output with rdflib will see ``Literal("x")`` (datatype - ``None``) rather than ``Literal("x", datatype=XSD.string)``. - -2. **Non-standard RDF**: Graphs with literal predicates (e.g. SHACL - annotation mode) or relative IRIs (e.g. the metamodel's - ``bibo:status ``) are rejected by pyoxigraph. This function - falls back to rdflib's serializer for such graphs, but still - canonicalizes blank-node labels (and sorts line-oriented formats) so - the fallback output remains deterministic across processes. - -3. **Numeric short forms**: pyoxigraph uses Turtle short forms for - ``xsd:integer`` (``42``), ``xsd:boolean`` (``true``), and - ``xsd:decimal`` (``1.23``). rdflib parses these back with the - correct datatype, so this is lossless. - -4. **Base IRI / prefix collision**: When a graph has ``@base`` and a - prefix whose namespace equals the base IRI (e.g. rdflib's auto-bound - ``base:`` prefix), pyoxigraph emits CURIEs like ``base:label`` that - rdflib rejects. We skip such prefixes during serialization. - -5. **Trailing escaped dot in PN_LOCAL**: pyoxigraph emits CURIEs like - ``prefix:local\\.`` for IRIs whose local part ends with ``.``. This - is valid Turtle (PN_LOCAL_ESC), but rdflib's notation3 parser rejects - it because it conflicts with the statement-terminator dot. We - post-process the output to expand such CURIEs to full ```` form. +"""Deterministic RDF serialization, delegated to the ``diffable-rdf`` library. + +This module used to carry the implementation. It was extracted into +`diffable-rdf `_ at the maintainers' +request (linkml/linkml#3295: *"the implementation should live elsewhere ... +independent rdflib sidecar library?"*), and this module is now a thin adapter +over it. :func:`canonicalize_rdf_graph` keeps its signature, so nothing that +imports it needs to change. + +The adapter exists for one reason: the two projects report degraded output +differently. The library logs through the ``logging`` module, which is silent +unless the application configured a handler. linkml deliberately uses +:func:`warnings.warn` so a schema author running ``gen-owl`` sees that the +output took a fallback path without having to opt in to logging first. So the +library's warnings are captured and re-emitted as +:class:`RDFCanonicalizationWarning`. + +What the library does, in short: the graph is transferred to pyoxigraph via +N-Triples, canonicalized with RDFC-1.0, sorted, and serialized back. Graphs +pyoxigraph refuses -- literal predicates from SHACL annotation mode, relative +IRIs such as the metamodel's ``bibo:status `` -- fall back to rdflib +with blank-node labels canonicalized by :func:`rdflib.compare.to_canonical_graph`, +which is content-derived rather than run-local, so the fallback is still +reproducible across processes. The full contract, including the limitations +that used to be listed here (``xsd:string`` normalization, numeric short forms, +base/prefix collisions, ``PN_LOCAL`` escaping), is documented in the library's +``docs/api.md``. + +Delegating also adopts nine correctness fixes the extracted copy received and +this one never did, two of them silent data corruption. Each is pinned by a +test in ``tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py``. """ -import io -import re +import logging import warnings -import pyoxigraph as ox import rdflib -from rdflib.compare import to_canonical_graph class RDFCanonicalizationWarning(UserWarning): @@ -55,358 +47,98 @@ class RDFCanonicalizationWarning(UserWarning): """ -# Mapping from rdflib/LinkML format strings to pyoxigraph RdfFormat objects. -_FORMAT_MAP: dict[str, ox.RdfFormat] = { - "turtle": ox.RdfFormat.TURTLE, - "ttl": ox.RdfFormat.TURTLE, - "nt": ox.RdfFormat.N_TRIPLES, - "ntriples": ox.RdfFormat.N_TRIPLES, - "n-triples": ox.RdfFormat.N_TRIPLES, - "nt11": ox.RdfFormat.N_TRIPLES, - "nquads": ox.RdfFormat.N_QUADS, - "n-quads": ox.RdfFormat.N_QUADS, - "xml": ox.RdfFormat.RDF_XML, - "rdf/xml": ox.RdfFormat.RDF_XML, - "trig": ox.RdfFormat.TRIG, - "n3": ox.RdfFormat.N3, -} - -# Formats that support prefix declarations. -_PREFIX_FORMATS = frozenset({ox.RdfFormat.TURTLE, ox.RdfFormat.TRIG, ox.RdfFormat.N3, ox.RdfFormat.RDF_XML}) - -# Line-oriented formats (one triple/quad per line) whose fallback output must -# be sorted, because rdflib does not emit them in a stable order even after -# blank-node canonicalization. -_LINE_ORIENTED_FORMATS = frozenset({"nt", "ntriples", "n-triples", "nt11", "nquads", "n-quads"}) - - -def _deterministic_fallback_serialize(graph: rdflib.Graph, output_format: str) -> str: - """Serialize a graph that pyoxigraph cannot canonicalize, deterministically. - - pyoxigraph rejects some graphs that rdflib accepts -- notably graphs - containing relative IRIs (e.g. the metamodel's ``bibo:status ``) - or literal predicates (SHACL annotation mode). A plain - ``graph.serialize()`` for such graphs is *not* reproducible across - processes: rdflib assigns blank-node labels non-deterministically, so the - structure and grouping of the output varies run to run. - - To degrade gracefully instead of silently emitting non-deterministic - output, we canonicalize blank-node labels with rdflib's own - isomorphism-based canonicalization (:func:`rdflib.compare.to_canonical_graph`, - which uses a content-derived hash, not run-local ids) and preserve the - original prefix and base bindings that the canonical graph drops. For - line-oriented formats we additionally sort the serialized lines, since - rdflib does not emit N-Triples/N-Quads in a stable order. - - Relative IRIs are preserved verbatim (not resolved against the base): the - goal is deterministic output, and silently rewriting ```` into an - absolute IRI would mask what is really a data problem in the source graph. - - :param graph: The rdflib Graph that pyoxigraph could not parse. - :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). - :return: Deterministic string serialization of the graph. - """ - canonical = to_canonical_graph(graph) - # to_canonical_graph builds a fresh graph without the source's namespace - # bindings; rebind them so the output does not fall back to rdflib's - # non-deterministic auto-generated ``ns1:``/``ns2:`` prefixes. - for prefix, namespace in graph.namespace_manager.namespaces(): - canonical.namespace_manager.bind(prefix, namespace, replace=True) - canonical.base = graph.base - serialized = canonical.serialize(format=output_format) - if output_format.lower() in _LINE_ORIENTED_FORMATS: - lines = [line for line in serialized.splitlines() if line.strip()] - return "\n".join(sorted(lines)) + "\n" - return serialized - - -# Characters that may appear escaped in a Turtle PN_LOCAL via PN_LOCAL_ESC. -_PN_LOCAL_ESC_UNESCAPE = re.compile(r"\\([_~.\-!$&'()*+,;=/?#@%])") - - -_CURIE_TRAILING_DOT_PATTERN = re.compile( - r"(?\"'\[\]]*?\\\.)" - r"(?=\s)" -) - - -def _iter_turtle_structural_spans(turtle_text: str): - """Yield ``(start, end, is_structural)`` spans of a Turtle string. - - Structural spans are regions outside of string literals, IRI refs, and - line comments — i.e. the only places where a CURIE can syntactically - appear. Non-structural spans (literal contents, IRI bodies, comments) - are yielded as-is so they round-trip unchanged. - - Handles single- and triple-quoted literals with backslash escapes for - both ``"`` and ``'`` delimiters, ``<...>`` IRI refs, and ``#...`` line - comments. - """ - n = len(turtle_text) - i = 0 - while i < n: - ch = turtle_text[i] - if ch in ('"', "'"): - # String literal — find matching delimiter, respecting triple- - # quote form and backslash escapes. - delim = ch - triple = turtle_text[i : i + 3] == delim * 3 - end_marker = delim * 3 if triple else delim - j = i + len(end_marker) - while j < n: - if turtle_text[j] == "\\" and j + 1 < n: - j += 2 - continue - if turtle_text[j : j + len(end_marker)] == end_marker: - j += len(end_marker) - break - j += 1 - yield i, j, False - i = j - elif ch == "<": - # IRI ref — find closing '>' on the same logical line. - j = turtle_text.find(">", i + 1) - if j == -1: - yield i, n, False - i = n - else: - yield i, j + 1, False - i = j + 1 - elif ch == "#": - # Line comment — to end of line. - j = turtle_text.find("\n", i) - j = n if j == -1 else j - yield i, j, False - i = j - else: - # Structural region — accumulate until the next literal / IRI / - # comment opener. Treat ``\X`` as a PN_LOCAL_ESC escape (two - # chars) so that ``\#`` and ``\.`` inside a CURIE local part - # don't trigger comment / boundary handling. - start = i - while i < n: - c = turtle_text[i] - if c == "\\" and i + 1 < n: - i += 2 - continue - if c in ('"', "'", "<", "#"): - break - i += 1 - yield start, i, True - - -def _expand_trailing_dot_curies(turtle_text: str, prefixes: dict[str, str]) -> str: - """Replace CURIEs whose local part ends in ``\\.`` with full ```` form. +_LIBRARY_LOGGER = "diffable_rdf" - rdflib's notation3 parser rejects PN_LOCAL ending in an escaped dot - even though Turtle permits it (PN_LOCAL_ESC). pyoxigraph emits this - form for IRIs ending in ``.`` (e.g. ``biolink:StrandEnum#.``). We - rewrite each such CURIE to its expanded ```` form so the output - round-trips through rdflib. - The rewrite is applied only to *structural* spans of the document — - string literals, IRI refs, and comments are left untouched. This is - what prevents the regex from mangling CURIE-shaped substrings that - happen to appear inside a literal value. - """ - if not prefixes: - return turtle_text - - def replace(match: re.Match[str]) -> str: - prefix = match.group(1) - local_escaped = match.group(2) - namespace = prefixes.get(prefix) - if namespace is None: - return match.group(0) - local = _PN_LOCAL_ESC_UNESCAPE.sub(r"\1", local_escaped) - return f"<{namespace}{local}>" - - parts: list[str] = [] - for start, end, is_structural in _iter_turtle_structural_spans(turtle_text): - chunk = turtle_text[start:end] - if is_structural: - chunk = _CURIE_TRAILING_DOT_PATTERN.sub(replace, chunk) - parts.append(chunk) - return "".join(parts) +class _DegradedPathWarnings(logging.Handler): + """Collect the library's warning logs and re-emit them as Python warnings. + Records are collected during the call and re-emitted in :meth:`__exit__` + rather than from :meth:`emit`. Warning at the moment the record arrives + would put eight ``logging`` frames plus an unknown number of library frames + between :func:`warnings.warn` and the caller, and the library's depth + differs per message, so no single ``stacklevel`` could point at the caller. + Re-emitting from ``__exit__`` makes the distance a constant. -def _iri_terms(triples: list["ox.Triple"]) -> set[str]: - """Return the set of IRI strings appearing anywhere in ``triples``. + The handler is attached to the library's package logger rather than the + root logger. The library logs under module-level children such as + ``diffable_rdf.canonicalize``, which propagate to the package logger, so + attaching there catches every module without naming any of them. - Walks subjects, predicates, non-literal objects, and literal datatypes. - Used to filter the prefix dict down to namespaces that are actually - referenced by the canonicalized graph, so the output isn't padded with - unused ``@prefix`` declarations. + ``propagate`` is left alone: a caller who *did* configure logging still + gets the record through their own handlers, and seeing the message once + per mechanism is a better trade than suppressing a handler they asked for. """ - iris: set[str] = set() - for t in triples: - for term in (t.subject, t.predicate, t.object): - if isinstance(term, ox.NamedNode): - iris.add(term.value) - elif isinstance(term, ox.Literal): - dt = term.datatype - if dt is not None: - iris.add(dt.value) - return iris - -def _filter_prefixes_to_used(prefixes: dict[str, str], used_iris: set[str]) -> dict[str, str]: - """Drop prefix bindings whose namespace is not a prefix of any used IRI. - - A prefix is kept if at least one IRI in ``used_iris`` starts with its - namespace string. Parent-namespace matches are honored (e.g. a prefix - bound to ``http://schema.org/`` is kept when ``http://schema.org/Person`` - appears in the graph). - """ - return {prefix: ns for prefix, ns in prefixes.items() if any(iri.startswith(ns) for iri in used_iris)} - - -def _is_safe_prefix_iri(iri: str) -> bool: - """Check whether a namespace IRI is safe for prefix serialization. - - pyoxigraph rejects IRIs with invalid code-points (e.g. double ``#``), - and rdflib's Turtle parser cannot round-trip CURIEs whose namespace - contains query parameters or fragments in unexpected positions. This - function returns ``False`` for such IRIs so they can be skipped during - prefix collection. - """ - # A namespace IRI should end with '/' or '#'. If '#' appears - # *before* the final character, the IRI contains an embedded - # fragment which produces unusable CURIEs. - if "#" in iri[:-1]: - return False - # Query parameters in namespace IRIs produce CURIEs that rdflib - # cannot parse back. - if "?" in iri: - return False - return True + def __init__(self) -> None: + super().__init__(level=logging.WARNING) + self._messages: list[str] = [] + self._logger = logging.getLogger(_LIBRARY_LOGGER) + self._previous_level = logging.NOTSET + + def emit(self, record: logging.LogRecord) -> None: + """Record one formatted message for re-emission on exit.""" + self._messages.append(record.getMessage()) + + def __enter__(self) -> "_DegradedPathWarnings": + """Attach to the library's logger, raising its level if it is silent.""" + self._previous_level = self._logger.level + if not self._logger.isEnabledFor(logging.WARNING): + self._logger.setLevel(logging.WARNING) + self._logger.addHandler(self) + return self + + def __exit__(self, *exc_info: object) -> None: + """Detach, restore the logger, and re-emit what was collected. + + Runs on the exception path too, so a caller who gets an exception still + learns about the degradation that preceded it. + """ + self._logger.removeHandler(self) + self._logger.setLevel(self._previous_level) + for message in self._messages: + # 3 frames: warnings.warn, this method, and the ``with`` statement + # in canonicalize_rdf_graph -- so the warning lands on its caller. + warnings.warn(message, RDFCanonicalizationWarning, stacklevel=3) def canonicalize_rdf_graph( graph: rdflib.Graph, output_format: str = "turtle", + diff_stable: bool = False, ) -> str: """Serialize an rdflib Graph deterministically using RDFC-1.0 canonicalization. - The graph is transferred to pyoxigraph via N-Triples, canonicalized - with RDFC-1.0, sorted, and serialized back to the requested format. - Prefix bindings from the rdflib Graph are preserved in the output - for formats that support them (Turtle, TriG, N3, RDF/XML). + The graph is transferred to pyoxigraph via N-Triples, canonicalized with + RDFC-1.0, sorted, and serialized back to the requested format. Prefix + bindings from the rdflib Graph are preserved in the output for formats that + support them (Turtle, TriG, N3, RDF/XML). - Falls back to plain rdflib serialization for unsupported formats or - graphs containing non-standard RDF (e.g. literal predicates). + Falls back to plain rdflib serialization for unsupported formats or graphs + containing non-standard RDF (e.g. literal predicates), warning with + :class:`RDFCanonicalizationWarning` so that the caller knows the output is + less strongly guaranteed than usual. :param graph: The rdflib Graph to serialize. :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). + :param diff_stable: Use diffable-rdf labels to reduce blank-node churn across + edits. Disabled by default; diffable-rdf is installed with the runtime. The + non-standard RDF fallback warns when it cannot apply these labels. + See https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control :return: Deterministic string serialization of the graph. + :raises ImportError: If diffable-rdf is unavailable, with an installation command. + :raises ValueError: If the graph cannot be serialized to ``output_format`` + in a form that parses back -- notably a line-oriented format asked to + write a relative IRI, which N-Triples forbids. """ - ox_format = _FORMAT_MAP.get(output_format.lower()) - if ox_format is None: - warnings.warn( - f"pyoxigraph does not support format {output_format!r}; falling back to " - "rdflib serializer. Output will not be deterministically canonicalized.", - RDFCanonicalizationWarning, - stacklevel=2, - ) - return graph.serialize(format=output_format) - - # 1. Transfer rdflib graph to pyoxigraph via N-Triples. - nt_data = graph.serialize(format="nt") - nt_bytes = nt_data.encode("utf-8") if isinstance(nt_data, str) else nt_data - - # 2. Parse into pyoxigraph and build a Dataset for canonicalization. - # Fall back to rdflib if the graph contains non-standard RDF - # (e.g. literal predicates from annotations) that pyoxigraph rejects. - try: - triples = list(ox.parse(io.BytesIO(nt_bytes), format=ox.RdfFormat.N_TRIPLES)) - except SyntaxError: - warnings.warn( - "Graph contains non-standard RDF (e.g. relative IRIs or literal predicates) " - "that pyoxigraph cannot parse; falling back to rdflib. Output is still " - "deterministic (blank-node labels are canonicalized via rdflib) but is not " - "canonicalized with pyoxigraph RDFC-1.0.", - RDFCanonicalizationWarning, - stacklevel=2, - ) - return _deterministic_fallback_serialize(graph, output_format) - - dataset = ox.Dataset() - for triple in triples: - dataset.add(ox.Quad(triple.subject, triple.predicate, triple.object, ox.DefaultGraph())) - - # 3. Canonicalize blank node labels with RDFC-1.0. - dataset.canonicalize(ox.CanonicalizationAlgorithm.RDFC_1_0) - - # 4. Sort triples for deterministic ordering. - # RDFC-1.0 stabilizes blank-node labels but pyoxigraph's Dataset - # iteration order is not sorted and varies across processes (verified - # empirically against pyoxigraph 0.5.8). The explicit string-key sort - # is load-bearing for byte-identical output across runs; see - # tests/linkml_runtime/test_utils/test_rdf_canonicalize.py::test_sort_is_load_bearing. - quads = list(dataset) - sorted_triples = sorted( - (ox.Triple(q.subject, q.predicate, q.object) for q in quads), - key=lambda t: (str(t.subject), str(t.predicate), str(t.object)), - ) - - # 5. Collect prefixes for formats that support them. - base_iri = str(graph.base) if graph.base else None - prefixes: dict[str, str] | None = None - if ox_format in _PREFIX_FORMATS: - prefixes = {} - for prefix, namespace in graph.namespace_manager.namespaces(): - if not prefix: # skip empty prefix (base) - continue - ns_str = str(namespace) - # Skip prefixes whose namespace matches the base IRI to avoid - # pyoxigraph emitting CURIEs like `base:label` that conflict - # with the @base directive. - if base_iri and ns_str == base_iri: - continue - # Skip namespace IRIs that pyoxigraph rejects or that produce - # CURIEs rdflib cannot round-trip. Valid namespace IRIs for - # prefix use should end with '/' or '#' and contain no query - # parameters or fragment-like characters in the middle. - if not _is_safe_prefix_iri(ns_str): - continue - prefixes[str(prefix)] = ns_str - # Drop prefix bindings whose namespace is not referenced by any IRI - # in the graph. This prevents the rdflib NamespaceManager's default - # bindings (~30 well-known vocabularies) from being emitted into - # every output file regardless of whether the schema actually uses - # them. - prefixes = _filter_prefixes_to_used(prefixes, _iri_terms(sorted_triples)) - used_prefixes = prefixes try: - result_bytes = ox.serialize( - sorted_triples, - format=ox_format, - prefixes=prefixes, - base_iri=base_iri, - ) - except ValueError as e: - # pyoxigraph 0.5.x reports rejected prefix IRIs with a message - # that begins with "Invalid prefix" (verified empirically). Only - # swallow that case and retry without prefixes — any other - # ValueError (e.g. invalid base IRI, or an unrelated future - # serializer bug) must propagate so it surfaces as a stack trace - # rather than silently dropping all prefix declarations. - if not str(e).startswith("Invalid prefix"): - raise - warnings.warn( - f"pyoxigraph rejected one or more prefix IRIs ({e}); serializing without " - "prefix declarations. Output remains canonicalized but is more verbose.", - RDFCanonicalizationWarning, - stacklevel=2, - ) - result_bytes = ox.serialize( - sorted_triples, - format=ox_format, - ) - used_prefixes = None - result = result_bytes.decode("utf-8") - if ox_format in _PREFIX_FORMATS and used_prefixes: - result = _expand_trailing_dot_curies(result, used_prefixes) - return result + from diffable_rdf import canonicalize_rdf_graph as _canonicalize_rdf_graph + except ImportError as exc: + raise ImportError( + "diffable-rdf is required for RDF serialization. Install with: pip install 'linkml-runtime[diff-stable]'" + ) from exc + + # Frames between warnings.warn and this function's caller are counted in + # _DegradedPathWarnings.__exit__, which is where the re-emission happens. + with _DegradedPathWarnings(): + return _canonicalize_rdf_graph(graph, output_format=output_format, diff_stable=diff_stable) diff --git a/pyproject.toml b/pyproject.toml index 559cf265ac..fd62dc2986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,7 @@ markers = [ "yarrrml: End-to-end tests for the YARRRML generator", "typedbgen: Tests for the TypeDB generator", "arrays: Array specifications!", + "diffable_rdf: tests that exercise the diffable-rdf integration (--diff-stable); run as a canary against linkml in diffable-rdf's own CI", ] # https://docs.astral.sh/ruff/settings/ diff --git a/tests/conftest.py b/tests/conftest.py index bcf958b9e5..ba13d6f764 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -422,7 +422,8 @@ class MockImportErrorFinder(MetaPathFinder): """ Fake like we don't have a module when we really do. - see the ``mock_black_import`` fixture for example usage. + see the ``mock_missing_import`` fixture to hide any module, or ``mock_black_import`` + for a single-purpose example. .. note:: @@ -442,6 +443,40 @@ def find_spec(self, fullname, path, target): return None +@pytest.fixture(scope="function") +def mock_missing_import(): + """Pretend an optional dependency is not installed, for any module. + + Yields a callable taking a module name, so a test for one optional + dependency does not need a fixture of its own:: + + def test_requires_the_extra(mock_missing_import): + mock_missing_import("diffable_rdf") + with pytest.raises(ImportError, match="linkml-runtime[diff-stable]"): + ... + + See :class:`MockImportErrorFinder` for the caveat about reimporting + modules that already imported the one being hidden. + """ + removed = {} + finders = [] + + def hide(module: str) -> None: + for name, loaded in list(sys.modules.items()): + if name.startswith(module): + removed[name] = loaded + del sys.modules[name] + finder = MockImportErrorFinder(module) + finders.append(finder) + sys.meta_path.insert(0, finder) + + yield hide + + sys.modules.update(removed) + for finder in finders: + sys.meta_path.remove(finder) + + @pytest.fixture(scope="function") def mock_black_import(): """ diff --git a/tests/linkml/test_base/__snapshots__/annotations.json b/tests/linkml/test_base/__snapshots__/annotations.json index b6c3c554bf..bc51cec7c6 100644 --- a/tests/linkml/test_base/__snapshots__/annotations.json +++ b/tests/linkml/test_base/__snapshots__/annotations.json @@ -417,14 +417,12 @@ "definition_uri": "https://w3id.org/linkml/Annotatable", "description": "mixin for classes that support annotations", "from_schema": "https://w3id.org/linkml/annotations", - "exact_mappings": [ - "linkml:Annotatable" - ], "mixin": true, "slots": [ "annotations" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Annotatable", "@type": "ClassDefinition" }, { @@ -432,9 +430,6 @@ "definition_uri": "https://w3id.org/linkml/Annotation", "description": "a tag/value pair with the semantics of OWL Annotation", "from_schema": "https://w3id.org/linkml/annotations", - "exact_mappings": [ - "linkml:Annotation" - ], "is_a": "Extension", "mixins": [ "Annotatable" @@ -446,6 +441,7 @@ "annotations" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Annotation", "@type": "ClassDefinition" }, { @@ -457,6 +453,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -465,15 +462,13 @@ "description": "a tag/value pair used to add non-model information to an entry", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extension" - ], "slots": [ "extension_tag", "extension_value", "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extension", "@type": "ClassDefinition" }, { @@ -482,14 +477,12 @@ "description": "mixin for classes that support extension", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extensible" - ], "mixin": true, "slots": [ "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extensible", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_base/__snapshots__/annotations.ttl b/tests/linkml/test_base/__snapshots__/annotations.ttl index 34e75c8c31..6c58ada890 100644 --- a/tests/linkml/test_base/__snapshots__/annotations.ttl +++ b/tests/linkml/test_base/__snapshots__/annotations.ttl @@ -6,16 +6,16 @@ @prefix linkml: . @prefix pav: . linkml:Annotatable a linkml:ClassDefinition ; - skos:exactMatch "linkml:Annotatable"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/annotations"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Annotatable"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Annotatable"^^xsd:anyURI ; linkml:description "mixin for classes that support annotations" ; linkml:mixin true ; linkml:slot_usage _:c14n0 ; linkml:slots linkml:annotations . linkml:Annotation a linkml:ClassDefinition ; - skos:exactMatch "linkml:Annotation"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/annotations"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Annotation"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Annotation"^^xsd:anyURI ; linkml:description "a tag/value pair with the semantics of OWL Annotation" ; linkml:is_a linkml:Extension ; @@ -25,12 +25,13 @@ linkml:Annotation a linkml:ClassDefinition ; linkml:AnyValue a linkml:ClassDefinition ; skos:exactMatch "linkml:Any"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Any"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnyValue"^^xsd:anyURI ; linkml:imported_from "linkml:extensions" ; linkml:slot_usage _:c14n1 . linkml:Extensible a linkml:ClassDefinition ; - skos:exactMatch "linkml:Extensible"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:description "mixin for classes that support extension" ; linkml:imported_from "linkml:extensions" ; @@ -38,8 +39,8 @@ linkml:Extensible a linkml:ClassDefinition ; linkml:slot_usage _:c14n5 ; linkml:slots linkml:extensions . linkml:Extension a linkml:ClassDefinition ; - skos:exactMatch "linkml:Extension"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:description "a tag/value pair used to add non-model information to an entry" ; linkml:imported_from "linkml:extensions" ; diff --git a/tests/linkml/test_base/__snapshots__/extensions.json b/tests/linkml/test_base/__snapshots__/extensions.json index eaf4268fe4..f9604e0ea6 100644 --- a/tests/linkml/test_base/__snapshots__/extensions.json +++ b/tests/linkml/test_base/__snapshots__/extensions.json @@ -398,6 +398,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -405,15 +406,13 @@ "definition_uri": "https://w3id.org/linkml/Extension", "description": "a tag/value pair used to add non-model information to an entry", "from_schema": "https://w3id.org/linkml/extensions", - "exact_mappings": [ - "linkml:Extension" - ], "slots": [ "extension_tag", "extension_value", "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extension", "@type": "ClassDefinition" }, { @@ -421,14 +420,12 @@ "definition_uri": "https://w3id.org/linkml/Extensible", "description": "mixin for classes that support extension", "from_schema": "https://w3id.org/linkml/extensions", - "exact_mappings": [ - "linkml:Extensible" - ], "mixin": true, "slots": [ "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extensible", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_base/__snapshots__/extensions.ttl b/tests/linkml/test_base/__snapshots__/extensions.ttl index c59fd71927..74f4a613d4 100644 --- a/tests/linkml/test_base/__snapshots__/extensions.ttl +++ b/tests/linkml/test_base/__snapshots__/extensions.ttl @@ -8,19 +8,20 @@ linkml:AnyValue a linkml:ClassDefinition ; skos:exactMatch "linkml:Any"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Any"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnyValue"^^xsd:anyURI ; linkml:slot_usage _:c14n0 . linkml:Extensible a linkml:ClassDefinition ; - skos:exactMatch "linkml:Extensible"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:description "mixin for classes that support extension" ; linkml:mixin true ; linkml:slot_usage _:c14n4 ; linkml:slots linkml:extensions . linkml:Extension a linkml:ClassDefinition ; - skos:exactMatch "linkml:Extension"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:description "a tag/value pair used to add non-model information to an entry" ; linkml:slot_usage _:c14n3 ; diff --git a/tests/linkml/test_base/__snapshots__/meta.json b/tests/linkml/test_base/__snapshots__/meta.json index 594a2d4b90..acda6769d8 100644 --- a/tests/linkml/test_base/__snapshots__/meta.json +++ b/tests/linkml/test_base/__snapshots__/meta.json @@ -6805,6 +6805,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -6815,9 +6816,6 @@ "BasicSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:CommonMetadata" - ], "mixin": true, "slots": [ "description", @@ -6855,6 +6853,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/CommonMetadata", "@type": "ClassDefinition" }, { @@ -6872,9 +6871,6 @@ "data element", "object" ], - "exact_mappings": [ - "linkml:Element" - ], "abstract": true, "mixins": [ "Extensible", @@ -6927,6 +6923,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Element", "@type": "ClassDefinition" }, { @@ -6953,9 +6950,6 @@ "schema", "model" ], - "exact_mappings": [ - "linkml:SchemaDefinition" - ], "close_mappings": [ "qb:ComponentSet", "owl:Ontology" @@ -7030,6 +7024,7 @@ "schema_definition_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/SchemaDefinition", "tree_root": true, "@type": "ClassDefinition" }, @@ -7038,9 +7033,6 @@ "definition_uri": "https://w3id.org/linkml/TypeExpression", "description": "An abstract class grouping named types and anonymous type expressions", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:TypeExpression" - ], "is_a": "Expression", "mixin": true, "slots": [ @@ -7059,6 +7051,7 @@ "type_expression_all_of" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/TypeExpression", "@type": "ClassDefinition" }, { @@ -7066,9 +7059,6 @@ "definition_uri": "https://w3id.org/linkml/AnonymousTypeExpression", "description": "A type expression that is not a top-level named type definition. Used for nesting.", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousTypeExpression" - ], "mixins": [ "TypeExpression" ], @@ -7088,6 +7078,7 @@ "type_expression_all_of" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousTypeExpression", "@type": "ClassDefinition" }, { @@ -7100,9 +7091,6 @@ "OwlProfile" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:TypeDefinition" - ], "rank": 4, "is_a": "Element", "mixins": [ @@ -7172,6 +7160,7 @@ "type_expression_all_of" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/TypeDefinition", "@type": "ClassDefinition" }, { @@ -7183,9 +7172,6 @@ "BasicSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:SubsetDefinition" - ], "rank": 6, "is_a": "Element", "slots": [ @@ -7234,6 +7220,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/SubsetDefinition", "@type": "ClassDefinition" }, { @@ -7247,9 +7234,6 @@ "see_also": [ "https://en.wikipedia.org/wiki/Data_element_definition" ], - "exact_mappings": [ - "linkml:Definition" - ], "is_a": "Element", "abstract": true, "slots": [ @@ -7305,6 +7289,7 @@ "string_serialization" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Definition", "@type": "ClassDefinition" }, { @@ -7312,9 +7297,6 @@ "definition_uri": "https://w3id.org/linkml/EnumExpression", "description": "An expression that constrains the range of a slot", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:EnumExpression" - ], "is_a": "Expression", "slots": [ "code_set", @@ -7330,6 +7312,7 @@ "concepts" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/EnumExpression", "@type": "ClassDefinition" }, { @@ -7337,9 +7320,6 @@ "definition_uri": "https://w3id.org/linkml/AnonymousEnumExpression", "description": "An enum_expression that is not named", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousEnumExpression" - ], "mixins": [ "EnumExpression" ], @@ -7357,6 +7337,7 @@ "concepts" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousEnumExpression", "@type": "ClassDefinition" }, { @@ -7384,7 +7365,6 @@ "value domain" ], "exact_mappings": [ - "linkml:EnumDefinition", "qb:HierarchicalCodeList", "NCIT:C113497", "cdisc:ValueDomain" @@ -7462,6 +7442,7 @@ "concepts" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/EnumDefinition", "@type": "ClassDefinition" }, { @@ -7472,9 +7453,6 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:EnumBinding" - ], "mixins": [ "Extensible", "Annotatable", @@ -7522,6 +7500,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/EnumBinding", "@type": "ClassDefinition" }, { @@ -7532,14 +7511,12 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:MatchQuery" - ], "slots": [ "identifier_pattern", "source_ontology" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/MatchQuery", "@type": "ClassDefinition" }, { @@ -7550,9 +7527,6 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ReachabilityQuery" - ], "slots": [ "source_ontology", "source_nodes", @@ -7562,6 +7536,7 @@ "traverse_up" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ReachabilityQuery", "@type": "ClassDefinition" }, { @@ -7619,6 +7594,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "http://www.w3.org/2008/05/skos-xl#Label", "@type": "ClassDefinition" }, { @@ -7626,12 +7602,10 @@ "definition_uri": "https://w3id.org/linkml/Expression", "description": "general mixin for any class that can represent some form of expression", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:Expression" - ], "abstract": true, "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Expression", "@type": "ClassDefinition" }, { @@ -7642,9 +7616,6 @@ "anonymous expressions are useful for when it is necessary to build a complex expression without introducing a named element for each sub-expression" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousExpression" - ], "abstract": true, "mixins": [ "Expression", @@ -7690,6 +7661,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousExpression", "@type": "ClassDefinition" }, { @@ -7697,9 +7669,6 @@ "definition_uri": "https://w3id.org/linkml/PathExpression", "description": "An expression that describes an abstract path from an object to another through a sequence of slot lookups", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:PathExpression" - ], "mixins": [ "Expression", "Extensible", @@ -7752,6 +7721,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/PathExpression", "@type": "ClassDefinition" }, { @@ -7759,9 +7729,6 @@ "definition_uri": "https://w3id.org/linkml/SlotExpression", "description": "an expression that constrains the range of values a slot can take", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:SlotExpression" - ], "is_a": "Expression", "mixin": true, "slots": [ @@ -7797,15 +7764,13 @@ "array" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/SlotExpression", "@type": "ClassDefinition" }, { "name": "AnonymousSlotExpression", "definition_uri": "https://w3id.org/linkml/AnonymousSlotExpression", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousSlotExpression" - ], "is_a": "AnonymousExpression", "mixins": [ "SlotExpression" @@ -7878,6 +7843,7 @@ "array" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousSlotExpression", "@type": "ClassDefinition" }, { @@ -7899,9 +7865,6 @@ "column", "variable" ], - "exact_mappings": [ - "linkml:SlotDefinition" - ], "close_mappings": [ "rdf:Property", "qb:ComponentProperty" @@ -8031,6 +7994,7 @@ "array" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/SlotDefinition", "@type": "ClassDefinition" }, { @@ -8038,9 +8002,6 @@ "definition_uri": "https://w3id.org/linkml/ClassExpression", "description": "A boolean expression that can be used to dynamically determine membership of a class", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ClassExpression" - ], "mixin": true, "slots": [ "class_expression_any_of", @@ -8050,15 +8011,13 @@ "slot_conditions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ClassExpression", "@type": "ClassDefinition" }, { "name": "AnonymousClassExpression", "definition_uri": "https://w3id.org/linkml/AnonymousClassExpression", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousClassExpression" - ], "is_a": "AnonymousExpression", "mixins": [ "ClassExpression" @@ -8107,6 +8066,7 @@ "slot_conditions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousClassExpression", "@type": "ClassDefinition" }, { @@ -8129,9 +8089,6 @@ "message", "observation" ], - "exact_mappings": [ - "linkml:ClassDefinition" - ], "close_mappings": [ "owl:Class" ], @@ -8215,6 +8172,7 @@ "slot_conditions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ClassDefinition", "@type": "ClassDefinition" }, { @@ -8222,11 +8180,9 @@ "definition_uri": "https://w3id.org/linkml/ClassLevelRule", "description": "A rule that is applied to classes", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ClassLevelRule" - ], "abstract": true, "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ClassLevelRule", "@type": "ClassDefinition" }, { @@ -8240,9 +8196,6 @@ "aliases": [ "if rule" ], - "exact_mappings": [ - "linkml:ClassRule" - ], "close_mappings": [ "sh:TripleRule", "swrl:Imp" @@ -8297,6 +8250,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ClassRule", "@type": "ClassDefinition" }, { @@ -8304,9 +8258,6 @@ "definition_uri": "https://w3id.org/linkml/ArrayExpression", "description": "defines the dimensions of an array", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ArrayExpression" - ], "status": "testing", "mixins": [ "Extensible", @@ -8355,6 +8306,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ArrayExpression", "@type": "ClassDefinition" }, { @@ -8362,9 +8314,6 @@ "definition_uri": "https://w3id.org/linkml/DimensionExpression", "description": "defines one of the dimensions of an array", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:DimensionExpression" - ], "status": "testing", "mixins": [ "Extensible", @@ -8413,6 +8362,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/DimensionExpression", "@type": "ClassDefinition" }, { @@ -8420,9 +8370,6 @@ "definition_uri": "https://w3id.org/linkml/PatternExpression", "description": "a regular expression pattern used to evaluate conformance of a string", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:PatternExpression" - ], "mixins": [ "Extensible", "Annotatable", @@ -8469,6 +8416,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/PatternExpression", "@type": "ClassDefinition" }, { @@ -8476,9 +8424,6 @@ "definition_uri": "https://w3id.org/linkml/ImportExpression", "description": "an expression describing an import", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ImportExpression" - ], "status": "testing", "mixins": [ "Extensible", @@ -8526,6 +8471,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ImportExpression", "@type": "ClassDefinition" }, { @@ -8536,14 +8482,12 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:Setting" - ], "slots": [ "setting_key", "setting_value" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Setting", "@type": "ClassDefinition" }, { @@ -8555,15 +8499,13 @@ "BasicSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:Prefix" - ], "rank": 12, "slots": [ "prefix_prefix", "prefix_reference" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Prefix", "@type": "ClassDefinition" }, { @@ -8571,14 +8513,12 @@ "definition_uri": "https://w3id.org/linkml/LocalName", "description": "an attributed label", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:LocalName" - ], "slots": [ "local_name_source", "local_name_value" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/LocalName", "@type": "ClassDefinition" }, { @@ -8589,15 +8529,13 @@ "BasicSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:Example" - ], "slots": [ "value", "value_description", "value_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Example", "@type": "ClassDefinition" }, { @@ -8611,14 +8549,12 @@ "aliases": [ "structured description" ], - "exact_mappings": [ - "linkml:AltDescription" - ], "slots": [ "alt_description_source", "alt_description_text" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AltDescription", "@type": "ClassDefinition" }, { @@ -8633,9 +8569,6 @@ "aliases": [ "PV" ], - "exact_mappings": [ - "linkml:PermissibleValue" - ], "close_mappings": [ "skos:Concept" ], @@ -8690,6 +8623,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/PermissibleValue", "@type": "ClassDefinition" }, { @@ -8702,9 +8636,6 @@ "RelationalModelProfile" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:UniqueKey" - ], "rank": 20, "mixins": [ "Extensible", @@ -8752,6 +8683,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/UniqueKey", "@type": "ClassDefinition" }, { @@ -8762,9 +8694,6 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:TypeMapping" - ], "rank": 21, "mixins": [ "Extensible", @@ -8812,6 +8741,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/TypeMapping", "@type": "ClassDefinition" }, { @@ -8819,9 +8749,6 @@ "definition_uri": "https://w3id.org/linkml/ExtraSlotsExpression", "description": "An expression that defines how to handle additional data in an instance of class\nbeyond the slots/attributes defined for that class.\nSee `extra_slots` for usage examples.\n", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ExtraSlotsExpression" - ], "mixins": [ "Expression" ], @@ -8830,6 +8757,7 @@ "extra_slots_expression_range_expression" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ExtraSlotsExpression", "@type": "ClassDefinition" }, { @@ -8841,6 +8769,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -8849,15 +8778,13 @@ "description": "a tag/value pair used to add non-model information to an entry", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extension" - ], "slots": [ "extension_tag", "extension_value", "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extension", "@type": "ClassDefinition" }, { @@ -8866,14 +8793,12 @@ "description": "mixin for classes that support extension", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extensible" - ], "mixin": true, "slots": [ "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extensible", "@type": "ClassDefinition" }, { @@ -8882,14 +8807,12 @@ "description": "mixin for classes that support annotations", "from_schema": "https://w3id.org/linkml/annotations", "imported_from": "linkml:annotations", - "exact_mappings": [ - "linkml:Annotatable" - ], "mixin": true, "slots": [ "annotations" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Annotatable", "@type": "ClassDefinition" }, { @@ -8898,9 +8821,6 @@ "description": "a tag/value pair with the semantics of OWL Annotation", "from_schema": "https://w3id.org/linkml/annotations", "imported_from": "linkml:annotations", - "exact_mappings": [ - "linkml:Annotation" - ], "is_a": "Extension", "mixins": [ "Annotatable" @@ -8912,6 +8832,7 @@ "annotations" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Annotation", "@type": "ClassDefinition" }, { @@ -8934,6 +8855,7 @@ "iec61360code" ], "slot_usage": {}, + "class_uri": "http://qudt.org/schema/qudt/Unit", "any_of": [ { "slot_conditions": [ diff --git a/tests/linkml/test_base/__snapshots__/meta.ttl b/tests/linkml/test_base/__snapshots__/meta.ttl index 5cef2ccfcc..aba88d9a0a 100644 --- a/tests/linkml/test_base/__snapshots__/meta.ttl +++ b/tests/linkml/test_base/__snapshots__/meta.ttl @@ -22,15 +22,15 @@ linkml:AltDescription OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; skos:altLabel "structured description" ; - skos:exactMatch linkml:AltDescription ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AltDescription"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AltDescription"^^xsd:anyURI ; linkml:description "an attributed description" ; linkml:slot_usage _:c14n16 ; linkml:slots linkml:alt_description_source , linkml:alt_description_text . linkml:Annotatable a linkml:ClassDefinition ; - skos:exactMatch linkml:Annotatable ; skos:inScheme "https://w3id.org/linkml/annotations"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Annotatable"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Annotatable"^^xsd:anyURI ; linkml:description "mixin for classes that support annotations" ; linkml:imported_from "linkml:annotations" ; @@ -38,8 +38,8 @@ linkml:Annotatable a linkml:ClassDefinition ; linkml:slot_usage _:c14n29 ; linkml:slots linkml:annotations . linkml:Annotation a linkml:ClassDefinition ; - skos:exactMatch linkml:Annotation ; skos:inScheme "https://w3id.org/linkml/annotations"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Annotation"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Annotation"^^xsd:anyURI ; linkml:description "a tag/value pair with the semantics of OWL Annotation" ; linkml:imported_from "linkml:annotations" ; @@ -48,42 +48,42 @@ linkml:Annotation a linkml:ClassDefinition ; linkml:slot_usage _:c14n92 ; linkml:slots linkml:annotations , linkml:extension_tag , linkml:extension_value , linkml:extensions . linkml:AnonymousClassExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousClassExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AnonymousClassExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousClassExpression"^^xsd:anyURI ; linkml:is_a linkml:AnonymousExpression ; linkml:mixins linkml:ClassExpression ; linkml:slot_usage _:c14n55 ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:anonymous_class_expression_is_a , linkml:broad_mappings , linkml:categories , linkml:class_expression_all_of , linkml:class_expression_any_of , linkml:class_expression_exactly_one_of , linkml:class_expression_none_of , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:slot_conditions , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:AnonymousEnumExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousEnumExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AnonymousEnumExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousEnumExpression"^^xsd:anyURI ; linkml:description "An enum_expression that is not named" ; linkml:mixins linkml:EnumExpression ; linkml:slot_usage _:c14n75 ; linkml:slots linkml:code_set , linkml:code_set_tag , linkml:code_set_version , linkml:concepts , linkml:include , linkml:inherits , linkml:matches , linkml:minus , linkml:permissible_values , linkml:pv_formula , linkml:reachable_from . linkml:AnonymousExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; skos:note "anonymous expressions are useful for when it is necessary to build a complex expression without introducing a named element for each sub-expression" ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/AnonymousExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousExpression"^^xsd:anyURI ; linkml:description "An abstract parent class for any nested expression" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Expression , linkml:Extensible ; linkml:slot_usage _:c14n81 ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:AnonymousSlotExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousSlotExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AnonymousSlotExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousSlotExpression"^^xsd:anyURI ; linkml:is_a linkml:AnonymousExpression ; linkml:mixins linkml:SlotExpression ; linkml:slot_usage _:c14n54 ; linkml:slots linkml:aliases , linkml:all_members , linkml:alt_descriptions , linkml:annotations , linkml:array , linkml:bindings , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:enum_range , linkml:equals_expression , linkml:equals_number , linkml:equals_string , linkml:equals_string_in , linkml:exact_cardinality , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:has_member , linkml:implicit_prefix , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:inlined , linkml:inlined_as_list , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:maximum_cardinality , linkml:maximum_value , linkml:minimum_cardinality , linkml:minimum_value , linkml:modified_by , linkml:multivalued , linkml:narrow_mappings , linkml:notes , linkml:pattern , linkml:range , linkml:range_expression , linkml:rank , linkml:recommended , linkml:related_mappings , linkml:required , linkml:see_also , linkml:slot_expression_all_of , linkml:slot_expression_any_of , linkml:slot_expression_exactly_one_of , linkml:slot_expression_none_of , linkml:source , linkml:status , linkml:structured_aliases , linkml:structured_pattern , linkml:title , linkml:todos , linkml:unit , linkml:value_presence . linkml:AnonymousTypeExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousTypeExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AnonymousTypeExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousTypeExpression"^^xsd:anyURI ; linkml:description "A type expression that is not a top-level named type definition. Used for nesting." ; linkml:mixins linkml:TypeExpression ; @@ -92,17 +92,19 @@ linkml:AnonymousTypeExpression a linkml:ClassDefinition ; linkml:AnyValue a linkml:ClassDefinition ; skos:exactMatch linkml:Any ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Any"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnyValue"^^xsd:anyURI ; linkml:imported_from "linkml:extensions" ; linkml:slot_usage _:c14n43 . linkml:Anything a linkml:ClassDefinition ; skos:exactMatch linkml:Any ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Any"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Anything"^^xsd:anyURI ; linkml:slot_usage _:c14n47 . linkml:ArrayExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:ArrayExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ArrayExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ArrayExpression"^^xsd:anyURI ; linkml:description "defines the dimensions of an array" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -122,9 +124,9 @@ linkml:ClassDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , a linkml:ClassDefinition ; skos:altLabel "message" , "observation" , "record" , "table" , "template" ; skos:closeMatch owl:Class ; - skos:exactMatch linkml:ClassDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 2 ; + linkml:class_uri "https://w3id.org/linkml/ClassDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ClassDefinition"^^xsd:anyURI ; linkml:description "an element whose instances are complex objects that may have slot-value assignments" ; linkml:is_a linkml:Definition ; @@ -132,17 +134,17 @@ linkml:ClassDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , linkml:slot_usage _:c14n35 ; linkml:slots linkml:abstract , linkml:alias , linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:attributes , linkml:broad_mappings , linkml:categories , linkml:children_are_mutually_disjoint , linkml:class_definition_apply_to , linkml:class_definition_disjoint_with , linkml:class_definition_is_a , linkml:class_definition_mixins , linkml:class_definition_rules , linkml:class_definition_union_of , linkml:class_expression_all_of , linkml:class_expression_any_of , linkml:class_expression_exactly_one_of , linkml:class_expression_none_of , linkml:class_uri , linkml:classification_rules , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:defining_slots , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:extra_slots , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:mixin , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:represents_relationship , linkml:see_also , linkml:slot_conditions , linkml:slot_names_unique , linkml:slot_usage , linkml:slots , linkml:source , linkml:status , linkml:string_serialization , linkml:structured_aliases , linkml:subclass_of , linkml:title , linkml:todos , linkml:tree_root , linkml:unique_keys , linkml:values_from . linkml:ClassExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:ClassExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ClassExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ClassExpression"^^xsd:anyURI ; linkml:description "A boolean expression that can be used to dynamically determine membership of a class" ; linkml:mixin true ; linkml:slot_usage _:c14n74 ; linkml:slots linkml:class_expression_all_of , linkml:class_expression_any_of , linkml:class_expression_exactly_one_of , linkml:class_expression_none_of , linkml:slot_conditions . linkml:ClassLevelRule a linkml:ClassDefinition ; - skos:exactMatch linkml:ClassLevelRule ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/ClassLevelRule"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ClassLevelRule"^^xsd:anyURI ; linkml:description "A rule that is applied to classes" ; linkml:slot_usage _:c14n88 . @@ -150,8 +152,8 @@ linkml:ClassRule OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; skos:altLabel "if rule" ; skos:closeMatch swrl:Imp , sh:TripleRule ; - skos:exactMatch linkml:ClassRule ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ClassRule"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ClassRule"^^xsd:anyURI ; linkml:description "A rule that applies to instances of a class" ; linkml:is_a linkml:ClassLevelRule ; @@ -160,8 +162,8 @@ linkml:ClassRule OIO:inSubset linkml:SpecificationSubset ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:bidirectional , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deactivated , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:elseconditions , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:open_world , linkml:postconditions , linkml:preconditions , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:CommonMetadata OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:CommonMetadata ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/CommonMetadata"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/CommonMetadata"^^xsd:anyURI ; linkml:description "Generic metadata shared across definitions" ; linkml:mixin true ; @@ -171,17 +173,17 @@ linkml:DISCOURAGED linkml:description "The metadata element is allowed but disco linkml:Definition OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; rdfs:seeAlso "https://en.wikipedia.org/wiki/Data_element_definition"^^xsd:anyURI ; - skos:exactMatch linkml:Definition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/Definition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Definition"^^xsd:anyURI ; linkml:description "abstract base class for core metaclasses" ; linkml:is_a linkml:Element ; linkml:slot_usage _:c14n8 ; linkml:slots linkml:abstract , linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:apply_to , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:is_a , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:mixin , linkml:mixins , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:string_serialization , linkml:structured_aliases , linkml:title , linkml:todos , linkml:values_from . linkml:DimensionExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:DimensionExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/DimensionExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/DimensionExpression"^^xsd:anyURI ; linkml:description "defines one of the dimensions of an array" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -194,9 +196,9 @@ linkml:Element OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; rdfs:seeAlso "https://en.wikipedia.org/wiki/Data_element"^^xsd:anyURI ; skos:altLabel "data element" , "object" ; - skos:exactMatch linkml:Element ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/Element"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Element"^^xsd:anyURI ; linkml:description "A named element in the model" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -204,8 +206,8 @@ linkml:Element OIO:inSubset linkml:BasicSubset ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:EnumBinding OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:EnumBinding ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/EnumBinding"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/EnumBinding"^^xsd:anyURI ; linkml:description "A binding of a slot or a class to a permissible value from an enumeration." ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -215,9 +217,10 @@ linkml:EnumDefinition OIO:inSubset linkml:BasicSubset , linkml:ObjectOrientedPro a linkml:ClassDefinition ; skos:altLabel "Terminology Value Set" , "answer list" , "code set" , "concept set" , "enum" , "enumeration" , "semantic enumeration" , "term set" , "value domain" , "value set" ; skos:closeMatch skos:ConceptScheme ; - skos:exactMatch NCIT:C113497 , qb:HierarchicalCodeList , cdisc:ValueDomain , linkml:EnumDefinition ; + skos:exactMatch NCIT:C113497 , qb:HierarchicalCodeList , cdisc:ValueDomain ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 5 ; + linkml:class_uri "https://w3id.org/linkml/EnumDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/EnumDefinition"^^xsd:anyURI ; linkml:description "an element whose instances must be drawn from a specified set of permissible values" ; linkml:is_a linkml:Definition ; @@ -225,8 +228,8 @@ linkml:EnumDefinition OIO:inSubset linkml:BasicSubset , linkml:ObjectOrientedPro linkml:slot_usage _:c14n6 ; linkml:slots linkml:abstract , linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:apply_to , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:code_set , linkml:code_set_tag , linkml:code_set_version , linkml:comments , linkml:concepts , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:enum_uri , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:include , linkml:inherits , linkml:instantiates , linkml:is_a , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:matches , linkml:minus , linkml:mixin , linkml:mixins , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:permissible_values , linkml:pv_formula , linkml:rank , linkml:reachable_from , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:string_serialization , linkml:structured_aliases , linkml:title , linkml:todos , linkml:values_from . linkml:EnumExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:EnumExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/EnumExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/EnumExpression"^^xsd:anyURI ; linkml:description "An expression that constrains the range of a slot" ; linkml:is_a linkml:Expression ; @@ -234,23 +237,23 @@ linkml:EnumExpression a linkml:ClassDefinition ; linkml:slots linkml:code_set , linkml:code_set_tag , linkml:code_set_version , linkml:concepts , linkml:include , linkml:inherits , linkml:matches , linkml:minus , linkml:permissible_values , linkml:pv_formula , linkml:reachable_from . linkml:Example OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:Example ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Example"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Example"^^xsd:anyURI ; linkml:description "usage example and description" ; linkml:slot_usage _:c14n66 ; linkml:slots linkml:value , linkml:value_description , linkml:value_object . linkml:Expression a linkml:ClassDefinition ; - skos:exactMatch linkml:Expression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/Expression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Expression"^^xsd:anyURI ; linkml:description "general mixin for any class that can represent some form of expression" ; linkml:mixin true ; linkml:slot_usage _:c14n94 . linkml:Extensible a linkml:ClassDefinition ; - skos:exactMatch linkml:Extensible ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:description "mixin for classes that support extension" ; linkml:imported_from "linkml:extensions" ; @@ -258,16 +261,16 @@ linkml:Extensible a linkml:ClassDefinition ; linkml:slot_usage _:c14n87 ; linkml:slots linkml:extensions . linkml:Extension a linkml:ClassDefinition ; - skos:exactMatch linkml:Extension ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:description "a tag/value pair used to add non-model information to an entry" ; linkml:imported_from "linkml:extensions" ; linkml:slot_usage _:c14n73 ; linkml:slots linkml:extension_tag , linkml:extension_value , linkml:extensions . linkml:ExtraSlotsExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:ExtraSlotsExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ExtraSlotsExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ExtraSlotsExpression"^^xsd:anyURI ; linkml:description "An expression that defines how to handle additional data in an instance of class\nbeyond the slots/attributes defined for that class.\nSee `extra_slots` for usage examples.\n" ; linkml:mixins linkml:Expression ; @@ -275,8 +278,8 @@ linkml:ExtraSlotsExpression a linkml:ClassDefinition ; linkml:slots linkml:allowed , linkml:extra_slots_expression_range_expression . linkml:FHIR_CODING linkml:description "The permissible values are the set of FHIR coding elements derived from the code set" . linkml:ImportExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:ImportExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ImportExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ImportExpression"^^xsd:anyURI ; linkml:description "an expression describing an import" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -285,16 +288,16 @@ linkml:ImportExpression a linkml:ClassDefinition ; linkml:status "testing" . linkml:LABEL linkml:description "The permissible values are the set of human readable labels in the code set" . linkml:LocalName a linkml:ClassDefinition ; - skos:exactMatch linkml:LocalName ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/LocalName"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/LocalName"^^xsd:anyURI ; linkml:description "an attributed label" ; linkml:slot_usage _:c14n91 ; linkml:slots linkml:local_name_source , linkml:local_name_value . linkml:MatchQuery OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:MatchQuery ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/MatchQuery"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/MatchQuery"^^xsd:anyURI ; linkml:description "A query that is used on an enum expression to dynamically obtain a set of permissible values via a query that matches on properties of the external concepts." ; linkml:slot_usage _:c14n48 ; @@ -327,16 +330,16 @@ linkml:PREDICATE skos:exactMatch owl:annotatedProperty ; linkml:description "a slot with this role connects a relationship to its predicate/property" ; linkml:meaning "rdf:predicate"^^xsd:anyURI . linkml:PathExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:PathExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/PathExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/PathExpression"^^xsd:anyURI ; linkml:description "An expression that describes an abstract path from an object to another through a sequence of slot lookups" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Expression , linkml:Extensible ; linkml:slot_usage _:c14n67 ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:path_expression_all_of , linkml:path_expression_any_of , linkml:path_expression_exactly_one_of , linkml:path_expression_followed_by , linkml:path_expression_none_of , linkml:range_expression , linkml:rank , linkml:related_mappings , linkml:reversed , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos , linkml:traverse . linkml:PatternExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:PatternExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/PatternExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/PatternExpression"^^xsd:anyURI ; linkml:description "a regular expression pattern used to evaluate conformance of a string" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -346,9 +349,9 @@ linkml:PermissibleValue OIO:inSubset linkml:BasicSubset , linkml:SpecificationSu a linkml:ClassDefinition ; skos:altLabel "PV" ; skos:closeMatch skos:Concept ; - skos:exactMatch linkml:PermissibleValue ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 16 ; + linkml:class_uri "https://w3id.org/linkml/PermissibleValue"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/PermissibleValue"^^xsd:anyURI ; linkml:description "a permissible value, accompanied by intended text and an optional mapping to a concept URI" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -356,9 +359,9 @@ linkml:PermissibleValue OIO:inSubset linkml:BasicSubset , linkml:SpecificationSu linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:meaning , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:permissible_value_is_a , linkml:permissible_value_mixins , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:text , linkml:title , linkml:todos , linkml:unit . linkml:Prefix OIO:inSubset linkml:BasicSubset , linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:Prefix ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 12 ; + linkml:class_uri "https://w3id.org/linkml/Prefix"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Prefix"^^xsd:anyURI ; linkml:description "prefix URI tuple" ; linkml:slot_usage _:c14n50 ; @@ -369,8 +372,8 @@ linkml:RELATED_SYNONYM linkml:meaning "skos:relatedMatch"^^xsd:anyURI . linkml:REQUIRED linkml:description "The metadata element is required to be present in the model" . linkml:ReachabilityQuery OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:ReachabilityQuery ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ReachabilityQuery"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ReachabilityQuery"^^xsd:anyURI ; linkml:description "A query that is used on an enum expression to dynamically obtain a set of permissible values via walking from a set of source nodes to a set of descendants or ancestors over a set of relationship types." ; linkml:slot_usage _:c14n25 ; @@ -389,9 +392,9 @@ linkml:SchemaDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , rdfs:seeAlso "https://en.wikipedia.org/wiki/Data_dictionary"^^xsd:anyURI ; skos:altLabel "data dictionary" , "data model" , "information model" , "logical model" , "model" , "schema" ; skos:closeMatch qb:ComponentSet , owl:Ontology ; - skos:exactMatch linkml:SchemaDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 1 ; + linkml:class_uri "https://w3id.org/linkml/SchemaDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/SchemaDefinition"^^xsd:anyURI ; linkml:description "A collection of definitions that make up a schema or a data model." ; linkml:is_a linkml:Element ; @@ -400,8 +403,8 @@ linkml:SchemaDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , linkml:tree_root true . linkml:Setting OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:Setting ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Setting"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Setting"^^xsd:anyURI ; linkml:description "assignment of a key to a value" ; linkml:slot_usage _:c14n57 ; @@ -410,9 +413,9 @@ linkml:SlotDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , l a linkml:ClassDefinition ; skos:altLabel "attribute" , "column" , "field" , "property" , "slot" , "variable" ; skos:closeMatch qb:ComponentProperty , rdf:Property ; - skos:exactMatch linkml:SlotDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 3 ; + linkml:class_uri "https://w3id.org/linkml/SlotDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/SlotDefinition"^^xsd:anyURI ; linkml:description "an element that describes how instances are related to other instances" ; linkml:is_a linkml:Definition ; @@ -420,8 +423,8 @@ linkml:SlotDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , l linkml:slot_usage _:c14n41 ; linkml:slots linkml:abstract , linkml:alias , linkml:aliases , linkml:all_members , linkml:alt_descriptions , linkml:annotations , linkml:array , linkml:asymmetric , linkml:bindings , linkml:broad_mappings , linkml:categories , linkml:children_are_mutually_disjoint , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:designates_type , linkml:domain , linkml:domain_of , linkml:enum_range , linkml:equals_expression , linkml:equals_number , linkml:equals_string , linkml:equals_string_in , linkml:exact_cardinality , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:has_member , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:identifier , linkml:ifabsent , linkml:implements , linkml:implicit_prefix , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:inherited , linkml:inlined , linkml:inlined_as_list , linkml:instantiates , linkml:inverse , linkml:irreflexive , linkml:is_class_field , linkml:is_grouping_slot , linkml:is_usage_slot , linkml:key , linkml:keywords , linkml:last_updated_on , linkml:list_elements_ordered , linkml:list_elements_unique , linkml:local_names , linkml:locally_reflexive , linkml:mappings , linkml:maximum_cardinality , linkml:maximum_value , linkml:minimum_cardinality , linkml:minimum_value , linkml:mixin , linkml:modified_by , linkml:multivalued , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:owner , linkml:path_rule , linkml:pattern , linkml:range , linkml:range_expression , linkml:rank , linkml:readonly , linkml:recommended , linkml:reflexive , linkml:reflexive_transitive_form_of , linkml:related_mappings , linkml:relational_role , linkml:required , linkml:role , linkml:see_also , linkml:shared , linkml:singular_name , linkml:slot_definition_apply_to , linkml:slot_definition_disjoint_with , linkml:slot_definition_is_a , linkml:slot_definition_mixins , linkml:slot_definition_union_of , linkml:slot_expression_all_of , linkml:slot_expression_any_of , linkml:slot_expression_exactly_one_of , linkml:slot_expression_none_of , linkml:slot_group , linkml:slot_uri , linkml:source , linkml:status , linkml:string_serialization , linkml:structured_aliases , linkml:structured_pattern , linkml:subproperty_of , linkml:symmetric , linkml:title , linkml:todos , linkml:transitive , linkml:transitive_form_of , linkml:type_mappings , linkml:unit , linkml:usage_slot_name , linkml:value_presence , linkml:values_from . linkml:SlotExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:SlotExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/SlotExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/SlotExpression"^^xsd:anyURI ; linkml:description "an expression that constrains the range of values a slot can take" ; linkml:is_a linkml:Expression ; @@ -437,6 +440,7 @@ linkml:SpecificationSubset dcterms:title "specification subset" ; linkml:StructuredAlias a linkml:ClassDefinition ; skos:exactMatch skosxl:Label ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "http://www.w3.org/2008/05/skos-xl#Label"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/StructuredAlias"^^xsd:anyURI ; linkml:description "object that contains meta data about a synonym or alias including where it came from (source) and its scope (narrow, broad, etc.)" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Expression , linkml:Extensible ; @@ -444,9 +448,9 @@ linkml:StructuredAlias a linkml:ClassDefinition ; linkml:slots linkml:alias_contexts , linkml:alias_predicate , linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:literal_form , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_alias_categories , linkml:structured_aliases , linkml:title , linkml:todos . linkml:SubsetDefinition OIO:inSubset linkml:BasicSubset , linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:SubsetDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 6 ; + linkml:class_uri "https://w3id.org/linkml/SubsetDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/SubsetDefinition"^^xsd:anyURI ; linkml:description "an element that can be used to group other metamodel elements" ; linkml:is_a linkml:Element ; @@ -454,9 +458,9 @@ linkml:SubsetDefinition OIO:inSubset linkml:BasicSubset , linkml:SpecificationSu linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:TypeDefinition OIO:inSubset linkml:BasicSubset , linkml:OwlProfile , linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:TypeDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 4 ; + linkml:class_uri "https://w3id.org/linkml/TypeDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/TypeDefinition"^^xsd:anyURI ; linkml:description "an element that whose instances are atomic scalar values that can be mapped to primitive types" ; linkml:is_a linkml:Element ; @@ -464,8 +468,8 @@ linkml:TypeDefinition OIO:inSubset linkml:BasicSubset , linkml:OwlProfile , link linkml:slot_usage _:c14n1 ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:base , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:equals_number , linkml:equals_string , linkml:equals_string_in , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:implicit_prefix , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:maximum_value , linkml:minimum_value , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:pattern , linkml:rank , linkml:related_mappings , linkml:repr , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:structured_pattern , linkml:title , linkml:todos , linkml:type_definition_union_of , linkml:type_expression_all_of , linkml:type_expression_any_of , linkml:type_expression_exactly_one_of , linkml:type_expression_none_of , linkml:type_uri , linkml:typeof , linkml:unit . linkml:TypeExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:TypeExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/TypeExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/TypeExpression"^^xsd:anyURI ; linkml:description "An abstract class grouping named types and anonymous type expressions" ; linkml:is_a linkml:Expression ; @@ -474,9 +478,9 @@ linkml:TypeExpression a linkml:ClassDefinition ; linkml:slots linkml:equals_number , linkml:equals_string , linkml:equals_string_in , linkml:implicit_prefix , linkml:maximum_value , linkml:minimum_value , linkml:pattern , linkml:structured_pattern , linkml:type_expression_all_of , linkml:type_expression_any_of , linkml:type_expression_exactly_one_of , linkml:type_expression_none_of , linkml:unit . linkml:TypeMapping OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:TypeMapping ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 21 ; + linkml:class_uri "https://w3id.org/linkml/TypeMapping"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/TypeMapping"^^xsd:anyURI ; linkml:description "Represents how a slot or type can be serialized to a format." ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -485,9 +489,9 @@ linkml:TypeMapping OIO:inSubset linkml:SpecificationSubset ; linkml:URI linkml:description "The permissible values are the set of code URIs in the code set" . linkml:UniqueKey OIO:inSubset linkml:BasicSubset , linkml:RelationalModelProfile , linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:UniqueKey ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 20 ; + linkml:class_uri "https://w3id.org/linkml/UniqueKey"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/UniqueKey"^^xsd:anyURI ; linkml:description "a collection of slots whose values uniquely identify an instance of a class" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -497,6 +501,7 @@ linkml:UnitOfMeasure a linkml:ClassDefinition ; skos:exactMatch qudt:Unit ; skos:inScheme "https://w3id.org/linkml/units"^^xsd:anyURI ; linkml:any_of _:c14n13 , _:c14n58 , _:c14n72 , _:c14n9 ; + linkml:class_uri "http://qudt.org/schema/qudt/Unit"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/UnitOfMeasure"^^xsd:anyURI ; linkml:description "A unit of measure, or unit, is a particular quantity value that has been chosen as a scale for measuring other quantities the same kind (more generally of equivalent dimension)." ; linkml:imported_from "linkml:units" ; diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.json b/tests/linkml/test_biolink_model/__snapshots__/biolink.json index 8854bcb862..3080ce2608 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.json +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.json @@ -23274,14 +23274,12 @@ "definition_uri": "https://w3id.org/biolink/vocab/MappingCollection", "description": "A collection of deprecated mappings.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MappingCollection" - ], "abstract": true, "slots": [ "predicate_mappings" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MappingCollection", "tree_root": true, "@type": "ClassDefinition" }, @@ -23290,9 +23288,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PredicateMapping", "description": "A deprecated predicate mapping object contains the deprecated predicate and an example of the rewiring that should be done to use a qualified statement in its place.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PredicateMapping" - ], "slots": [ "mapped_predicate", "subject_aspect_qualifier", @@ -23317,6 +23312,7 @@ "broad_match" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PredicateMapping", "@type": "ClassDefinition" }, { @@ -23344,7 +23340,6 @@ "https://github.com/biolink/biolink-model/issues/486" ], "exact_mappings": [ - "biolink:OntologyClass", "owl:Class", "schema:Class" ], @@ -23353,6 +23348,7 @@ "id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OntologyClass", "@type": "ClassDefinition" }, { @@ -23360,11 +23356,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/Annotation", "description": "Biolink Model root class for entity annotations.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Annotation" - ], "abstract": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Annotation", "@type": "ClassDefinition" }, { @@ -23372,15 +23366,13 @@ "definition_uri": "https://w3id.org/biolink/vocab/QuantityValue", "description": "A value of an attribute that is quantitative and measurable, expressed as a combination of a unit and a numeric value", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:QuantityValue" - ], "is_a": "Annotation", "slots": [ "has_unit", "has_numeric_value" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/QuantityValue", "@type": "ClassDefinition" }, { @@ -23398,7 +23390,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Attribute", "SIO:000614" ], "is_a": "NamedThing", @@ -23423,6 +23414,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Attribute", "@type": "ClassDefinition" }, { @@ -23440,7 +23432,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalRole", "CHEBI:51086" ], "is_a": "Attribute", @@ -23462,6 +23453,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalRole", "@type": "ClassDefinition" }, { @@ -23469,7 +23461,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/BiologicalSex", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:BiologicalSex", "PATO:0000047" ], "is_a": "Attribute", @@ -23491,6 +23482,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BiologicalSex", "@type": "ClassDefinition" }, { @@ -23499,7 +23491,6 @@ "description": "An attribute corresponding to the phenotypic sex of the individual, based upon the reproductive organs present.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PhenotypicSex", "PATO:0001894" ], "is_a": "BiologicalSex", @@ -23521,6 +23512,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicSex", "@type": "ClassDefinition" }, { @@ -23529,7 +23521,6 @@ "description": "An attribute corresponding to the genotypic sex of the individual, based upon genotypic composition of sex chromosomes.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GenotypicSex", "PATO:0020000" ], "is_a": "BiologicalSex", @@ -23551,6 +23542,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypicSex", "@type": "ClassDefinition" }, { @@ -23559,9 +23551,6 @@ "description": "describes the severity of a phenotypic feature or disease", "deprecated": "True", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SeverityValue" - ], "is_a": "Attribute", "slots": [ "id", @@ -23581,41 +23570,36 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SeverityValue", "@type": "ClassDefinition" }, { "name": "RelationshipQuantifier", "definition_uri": "https://w3id.org/biolink/vocab/RelationshipQuantifier", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RelationshipQuantifier" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RelationshipQuantifier", "@type": "ClassDefinition" }, { "name": "SensitivityQuantifier", "definition_uri": "https://w3id.org/biolink/vocab/SensitivityQuantifier", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SensitivityQuantifier" - ], "is_a": "RelationshipQuantifier", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SensitivityQuantifier", "@type": "ClassDefinition" }, { "name": "SpecificityQuantifier", "definition_uri": "https://w3id.org/biolink/vocab/SpecificityQuantifier", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SpecificityQuantifier" - ], "is_a": "RelationshipQuantifier", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SpecificityQuantifier", "@type": "ClassDefinition" }, { @@ -23623,21 +23607,16 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathognomonicityQuantifier", "description": "A relationship quantifier between a variant or symptom and a disease, which is high when the presence of the feature implies the existence of the disease", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathognomonicityQuantifier" - ], "is_a": "SpecificityQuantifier", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathognomonicityQuantifier", "@type": "ClassDefinition" }, { "name": "FrequencyQuantifier", "definition_uri": "https://w3id.org/biolink/vocab/FrequencyQuantifier", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FrequencyQuantifier" - ], "is_a": "RelationshipQuantifier", "mixin": true, "slots": [ @@ -23647,6 +23626,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FrequencyQuantifier", "@type": "ClassDefinition" }, { @@ -23659,11 +23639,9 @@ ], "definition_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatment", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalOrDrugOrTreatment" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatment", "@type": "ClassDefinition" }, { @@ -23671,9 +23649,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Entity", "description": "Root Biolink Model class for all things and informational relationships, real or imagined.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Entity" - ], "abstract": true, "slots": [ "id", @@ -23686,6 +23661,7 @@ "deprecated" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Entity", "@type": "ClassDefinition" }, { @@ -23694,7 +23670,6 @@ "description": "a databased entity or concept/class", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:NamedThing", "BFO:0000001", "WIKIDATA:Q35120", "UMLSSG:OBJC", @@ -23717,6 +23692,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NamedThing", "@type": "ClassDefinition" }, { @@ -23724,14 +23700,12 @@ "definition_uri": "https://w3id.org/biolink/vocab/RelationshipType", "description": "An OWL property used as an edge label", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RelationshipType" - ], "is_a": "OntologyClass", "slots": [ "id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RelationshipType", "@type": "ClassDefinition" }, { @@ -23745,14 +23719,12 @@ "mappings": [ "WIKIDATA:Q427626" ], - "exact_mappings": [ - "biolink:TaxonomicRank" - ], "is_a": "OntologyClass", "slots": [ "id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TaxonomicRank", "@type": "ClassDefinition" }, { @@ -23773,7 +23745,6 @@ "taxonomic classification" ], "exact_mappings": [ - "biolink:OrganismTaxon", "WIKIDATA:Q16521", "STY:T001", "bioschemas:Taxon" @@ -23801,6 +23772,7 @@ "organism_taxon_has_taxonomic_rank" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxon", "@type": "ClassDefinition" }, { @@ -23809,7 +23781,6 @@ "description": "Something that happens at a given place and time.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Event", "NCIT:C25499", "STY:T051" ], @@ -23829,15 +23800,13 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Event", "@type": "ClassDefinition" }, { "name": "AdministrativeEntity", "definition_uri": "https://w3id.org/biolink/vocab/AdministrativeEntity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:AdministrativeEntity" - ], "is_a": "NamedThing", "abstract": true, "slots": [ @@ -23855,6 +23824,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AdministrativeEntity", "@type": "ClassDefinition" }, { @@ -23865,9 +23835,6 @@ "The data/metadata included in a Study Result object are typically a subset of data from a larger study data set, that are selected by a curator because they may be useful as evidence for deriving knowledge about a specific focus of the study. The notion of a 'study' here is defined broadly to include any research activity at any scale that is aimed at generating knowledge or hypotheses. This may include a single assay or computational analyses, or a larger scale clinical trial or experimental research investigation." ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:StudyResult" - ], "is_a": "InformationContentEntity", "abstract": true, "slots": [ @@ -23889,6 +23856,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/StudyResult", "@type": "ClassDefinition" }, { @@ -23897,7 +23865,6 @@ "description": "a detailed investigation and/or analysis", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Study", "NCIT:C63536" ], "close_mappings": [ @@ -23923,6 +23890,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Study", "@type": "ClassDefinition" }, { @@ -23930,9 +23898,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/StudyVariable", "description": "a variable that is used as a measure in the investigation of a study", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:StudyVariable" - ], "close_mappings": [ "STATO:0000258", "SIO:000367" @@ -23960,6 +23925,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/StudyVariable", "@type": "ClassDefinition" }, { @@ -23967,9 +23933,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CommonDataElement", "description": "A Common Data Element (CDE) is a standardized, precisely defined question, paired with a set of allowable responses, used systematically across different sites, studies, or clinical trials to ensure consistent data collection. Multiple CDEs (from one or more Collections) can be curated into Forms. (https://cde.nlm.nih.gov/home)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CommonDataElement" - ], "close_mappings": [ "NCIT:C19984" ], @@ -23993,6 +23956,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CommonDataElement", "@type": "ClassDefinition" }, { @@ -24000,9 +23964,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ConceptCountAnalysisResult", "description": "A result of a concept count analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ConceptCountAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24023,6 +23984,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ConceptCountAnalysisResult", "@type": "ClassDefinition" }, { @@ -24030,9 +23992,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ObservedExpectedFrequencyAnalysisResult", "description": "A result of a observed expected frequency analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ObservedExpectedFrequencyAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24053,6 +24012,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ObservedExpectedFrequencyAnalysisResult", "@type": "ClassDefinition" }, { @@ -24060,9 +24020,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/RelativeFrequencyAnalysisResult", "description": "A result of a relative frequency analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RelativeFrequencyAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24083,6 +24040,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RelativeFrequencyAnalysisResult", "@type": "ClassDefinition" }, { @@ -24090,9 +24048,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/TextMiningResult", "description": "A result of text mining.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:TextMiningResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24113,6 +24068,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TextMiningResult", "@type": "ClassDefinition" }, { @@ -24120,9 +24076,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChiSquaredAnalysisResult", "description": "A result of a chi squared analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChiSquaredAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24143,6 +24096,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChiSquaredAnalysisResult", "@type": "ClassDefinition" }, { @@ -24150,9 +24104,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/LogOddsAnalysisResult", "description": "A result of a log odds ratio analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:LogOddsAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24173,6 +24124,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/LogOddsAnalysisResult", "@type": "ClassDefinition" }, { @@ -24192,7 +24144,6 @@ "group" ], "exact_mappings": [ - "biolink:Agent", "prov:Agent", "dct:Agent" ], @@ -24222,6 +24173,7 @@ "agent_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Agent", "@type": "ClassDefinition" }, { @@ -24238,7 +24190,6 @@ "information entity" ], "exact_mappings": [ - "biolink:InformationContentEntity", "IAO:0000030" ], "narrow_mappings": [ @@ -24276,6 +24227,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/InformationContentEntity", "@type": "ClassDefinition" }, { @@ -24284,7 +24236,6 @@ "description": "an item that refers to a collection of data from a data source.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Dataset", "IAO:0000100", "dctypes:Dataset", "schema:dataset", @@ -24310,6 +24261,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Dataset", "@type": "ClassDefinition" }, { @@ -24318,7 +24270,6 @@ "description": "an item that holds distribution level information about a dataset.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:DatasetDistribution", "dcat:Distribution" ], "is_a": "InformationContentEntity", @@ -24342,6 +24293,7 @@ "distribution_download_url" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DatasetDistribution", "@type": "ClassDefinition" }, { @@ -24349,9 +24301,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DatasetVersion", "description": "an item that holds version level information about a dataset.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DatasetVersion" - ], "is_a": "InformationContentEntity", "slots": [ "id", @@ -24375,6 +24324,7 @@ "has_distribution" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DatasetVersion", "@type": "ClassDefinition" }, { @@ -24382,9 +24332,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DatasetSummary", "description": "an item that holds summary level information about a dataset.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DatasetSummary" - ], "is_a": "InformationContentEntity", "slots": [ "id", @@ -24407,6 +24354,7 @@ "source_logo" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DatasetSummary", "@type": "ClassDefinition" }, { @@ -24415,7 +24363,6 @@ "description": "Level of confidence in a statement", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ConfidenceLevel", "CIO:0000028", "SEPIO:0000187" ], @@ -24445,6 +24392,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ConfidenceLevel", "@type": "ClassDefinition" }, { @@ -24456,7 +24404,6 @@ "evidence code" ], "exact_mappings": [ - "biolink:EvidenceType", "ECO:0000000" ], "is_a": "InformationContentEntity", @@ -24482,6 +24429,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EvidenceType", "@type": "ClassDefinition" }, { @@ -24499,7 +24447,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Publication", "IAO:0000311" ], "narrow_mappings": [ @@ -24532,6 +24479,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Publication", "@type": "ClassDefinition" }, { @@ -24546,9 +24494,6 @@ "model_organism_database" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Book" - ], "is_a": "Publication", "slots": [ "iri", @@ -24575,6 +24520,7 @@ "book_type" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Book", "@type": "ClassDefinition" }, { @@ -24584,9 +24530,6 @@ "model_organism_database" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BookChapter" - ], "is_a": "Publication", "slots": [ "iri", @@ -24616,6 +24559,7 @@ "chapter" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BookChapter", "@type": "ClassDefinition" }, { @@ -24633,9 +24577,6 @@ "aliases": [ "journal" ], - "exact_mappings": [ - "biolink:Serial" - ], "is_a": "Publication", "slots": [ "iri", @@ -24665,6 +24606,7 @@ "serial_type" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Serial", "@type": "ClassDefinition" }, { @@ -24679,7 +24621,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Article", "SIO:000154", "fabio:article" ], @@ -24713,6 +24654,7 @@ "issue" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Article", "@type": "ClassDefinition" }, { @@ -24726,7 +24668,6 @@ "description": "an article, typically presenting results of research, that is published in an issue of a scientific journal.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:JournalArticle", "IAO:0000013", "fabio:JournalArticle" ], @@ -24760,6 +24701,7 @@ "issue" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/JournalArticle", "@type": "ClassDefinition" }, { @@ -24768,7 +24710,6 @@ "description": "a legal document granted by a patent issuing authority which confers upon the patenter the sole right to make, use and sell an invention for a set period of time.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Patent", "SIO:000153", "IAO:0000313", "fabio:Patent" @@ -24799,6 +24740,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Patent", "@type": "ClassDefinition" }, { @@ -24807,7 +24749,6 @@ "description": "a document that is published according to World Wide Web standards, which may incorporate text, graphics, sound, and/or other features.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:WebPage", "SIO:000302", "NCIT-OBO:C142749", "fabio:WebPage" @@ -24838,6 +24779,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/WebPage", "@type": "ClassDefinition" }, { @@ -24846,7 +24788,6 @@ "description": "a document reresenting an early version of an author's original scholarly work, such as a research paper or a review, prior to formal peer review and publication in a peer-reviewed scholarly or scientific journal.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PreprintPublication", "EFO:0010558", "fabio:Preprint" ], @@ -24876,6 +24817,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PreprintPublication", "@type": "ClassDefinition" }, { @@ -24883,9 +24825,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DrugLabel", "description": "a document accompanying a drug or its container that provides written, printed or graphic information about the drug, including drug contents, specific instructions or warnings for administration, storage and disposal instructions, etc.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DrugLabel" - ], "broad_mappings": [ "NCIT-OBO:C41203" ], @@ -24915,6 +24854,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugLabel", "@type": "ClassDefinition" }, { @@ -24922,9 +24862,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/RetrievalSource", "description": "Provides information about how a particular InformationResource served as a source from which knowledge expressed in an Edge, or data used to generate this knowledge, was retrieved.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RetrievalSource" - ], "is_a": "InformationContentEntity", "slots": [ "id", @@ -24948,6 +24885,7 @@ "xref" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RetrievalSource", "@type": "ClassDefinition" }, { @@ -24955,11 +24893,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/PhysicalEssenceOrOccurrent", "description": "Either a physical or processual entity.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhysicalEssenceOrOccurrent" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhysicalEssenceOrOccurrent", "@type": "ClassDefinition" }, { @@ -24967,12 +24903,10 @@ "definition_uri": "https://w3id.org/biolink/vocab/PhysicalEssence", "description": "Semantic mixin concept. Pertains to entities that have physical properties such as mass, volume, or charge.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhysicalEssence" - ], "is_a": "PhysicalEssenceOrOccurrent", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhysicalEssence", "@type": "ClassDefinition" }, { @@ -24981,7 +24915,6 @@ "description": "An entity that has material reality (a.k.a. physical essence).", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PhysicalEntity", "STY:T072" ], "narrow_mappings": [ @@ -25006,6 +24939,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhysicalEntity", "@type": "ClassDefinition" }, { @@ -25014,12 +24948,12 @@ "description": "A processual entity.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Occurrent", "BFO:0000003" ], "is_a": "PhysicalEssenceOrOccurrent", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Occurrent", "@type": "ClassDefinition" }, { @@ -25028,12 +24962,12 @@ "description": "Activity or behavior of any independent integral living, organization or mechanical actor in the world", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ActivityAndBehavior", "UMLSSG:ACTI" ], "is_a": "Occurrent", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ActivityAndBehavior", "@type": "ClassDefinition" }, { @@ -25042,7 +24976,6 @@ "description": "An activity is something that occurs over a period of time and acts upon or with entities; it may include consuming, processing, transforming, modifying, relocating, using, or generating entities.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Activity", "prov:Activity", "NCIT:C43431", "STY:T052" @@ -25075,6 +25008,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Activity", "@type": "ClassDefinition" }, { @@ -25086,7 +25020,6 @@ "description": "A series of actions conducted in a certain order or manner", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Procedure", "UMLSSG:PROC", "dcid:MedicalProcedure" ], @@ -25115,6 +25048,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Procedure", "@type": "ClassDefinition" }, { @@ -25123,7 +25057,6 @@ "description": "a fact or situation that is observed to exist or happen, especially one whose cause or explanation is in question", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Phenomenon", "UMLSSG:PHEN" ], "narrow_mappings": [ @@ -25155,6 +25088,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Phenomenon", "@type": "ClassDefinition" }, { @@ -25162,9 +25096,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Device", "description": "A thing made or adapted for a particular purpose, especially a piece of mechanical or electronic equipment", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Device" - ], "narrow_mappings": [ "UMLSSG:DEVI", "STY:T074", @@ -25187,6 +25118,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Device", "@type": "ClassDefinition" }, { @@ -25195,7 +25127,6 @@ "description": "A device or substance used to help diagnose disease or injury", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:DiagnosticAid", "STY:T130", "SNOMED:2949005" ], @@ -25215,6 +25146,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiagnosticAid", "@type": "ClassDefinition" }, { @@ -25222,9 +25154,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/StudyPopulation", "description": "A group of people banded together or treated as a group as participants in a research study.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:StudyPopulation" - ], "close_mappings": [ "WIKIDATA:Q7229825" ], @@ -25246,6 +25175,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/StudyPopulation", "@type": "ClassDefinition" }, { @@ -25253,11 +25183,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/SubjectOfInvestigation", "description": "An entity that has the role of being studied in an investigation, study, or experiment", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SubjectOfInvestigation" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SubjectOfInvestigation", "@type": "ClassDefinition" }, { @@ -25276,7 +25204,6 @@ "physical sample" ], "exact_mappings": [ - "biolink:MaterialSample", "OBI:0000747", "SIO:001050" ], @@ -25299,6 +25226,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MaterialSample", "@type": "ClassDefinition" }, { @@ -25306,9 +25234,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PlanetaryEntity", "description": "Any entity or process that exists at the level of the whole planet", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PlanetaryEntity" - ], "is_a": "NamedThing", "slots": [ "id", @@ -25325,6 +25250,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PlanetaryEntity", "@type": "ClassDefinition" }, { @@ -25332,7 +25258,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EnvironmentalProcess", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:EnvironmentalProcess", "ENVO:02500000" ], "is_a": "PlanetaryEntity", @@ -25354,6 +25279,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EnvironmentalProcess", "@type": "ClassDefinition" }, { @@ -25361,7 +25287,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EnvironmentalFeature", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:EnvironmentalFeature", "ENVO:01000254" ], "is_a": "PlanetaryEntity", @@ -25380,6 +25305,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EnvironmentalFeature", "@type": "ClassDefinition" }, { @@ -25388,7 +25314,6 @@ "description": "a location that can be described in lat/long coordinates", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GeographicLocation", "UMLSSG:GEOG", "STY:T083" ], @@ -25410,6 +25335,7 @@ "longitude" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeographicLocation", "@type": "ClassDefinition" }, { @@ -25417,9 +25343,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeographicLocationAtTime", "description": "a location that can be described in lat/long coordinates, for a particular time", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeographicLocationAtTime" - ], "is_a": "GeographicLocation", "slots": [ "id", @@ -25439,6 +25362,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeographicLocationAtTime", "@type": "ClassDefinition" }, { @@ -25446,15 +25370,13 @@ "definition_uri": "https://w3id.org/biolink/vocab/ThingWithTaxon", "description": "A mixin that can be used on any entity that can be taxonomically classified. This includes individual organisms; genes, their products and other molecular entities; body parts; biological processes", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ThingWithTaxon" - ], "mixin": true, "slots": [ "in_taxon", "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ThingWithTaxon", "@type": "ClassDefinition" }, { @@ -25464,9 +25386,6 @@ "aliases": [ "bioentity" ], - "exact_mappings": [ - "biolink:BiologicalEntity" - ], "narrow_mappings": [ "WIKIDATA:Q28845870", "STY:T050", @@ -25495,6 +25414,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BiologicalEntity", "@type": "ClassDefinition" }, { @@ -25504,9 +25424,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenomicEntity" - ], "narrow_mappings": [ "STY:T028", "GENO:0000897" @@ -25516,6 +25433,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenomicEntity", "@type": "ClassDefinition" }, { @@ -25525,14 +25443,12 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EpigenomicEntity" - ], "mixin": true, "slots": [ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EpigenomicEntity", "@type": "ClassDefinition" }, { @@ -25569,9 +25485,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularEntity" - ], "narrow_mappings": [ "STY:T088", "STY:T085", @@ -25600,6 +25513,7 @@ "is_metabolite" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularEntity", "@type": "ClassDefinition" }, { @@ -25637,7 +25551,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalEntity", "CHEBI:24431", "SIO:010004", "WIKIDATA:Q79529", @@ -25678,6 +25591,7 @@ "has_chemical_role" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntity", "@type": "ClassDefinition" }, { @@ -25718,9 +25632,6 @@ "aliases": [ "chemical substance" ], - "exact_mappings": [ - "biolink:SmallMolecule" - ], "narrow_mappings": [ "STY:T196", "CHEBI:59999", @@ -25758,6 +25669,7 @@ "is_metabolite" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SmallMolecule", "@type": "ClassDefinition" }, { @@ -25794,9 +25706,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalMixture" - ], "close_mappings": [ "dcid:ChemicalCompound" ], @@ -25829,6 +25738,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalMixture", "@type": "ClassDefinition" }, { @@ -25868,7 +25778,6 @@ "genomic entity" ], "exact_mappings": [ - "biolink:NucleicAcidEntity", "SO:0000110" ], "narrow_mappings": [ @@ -25906,6 +25815,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NucleicAcidEntity", "@type": "ClassDefinition" }, { @@ -25917,7 +25827,6 @@ "regulatory element" ], "exact_mappings": [ - "biolink:RegulatoryRegion", "SO:0005836", "SIO:001225", "WIKIDATA:Q3238407" @@ -25947,6 +25856,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RegulatoryRegion", "@type": "ClassDefinition" }, { @@ -25959,7 +25869,6 @@ "atac-seq accessible region" ], "exact_mappings": [ - "biolink:AccessibleDnaRegion", "SO:0002231" ], "is_a": "RegulatoryRegion", @@ -25987,6 +25896,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AccessibleDnaRegion", "@type": "ClassDefinition" }, { @@ -25999,7 +25909,6 @@ "binding site" ], "exact_mappings": [ - "biolink:TranscriptionFactorBindingSite", "SO:0000235" ], "is_a": "RegulatoryRegion", @@ -26027,6 +25936,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TranscriptionFactorBindingSite", "@type": "ClassDefinition" }, { @@ -26063,9 +25973,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularMixture" - ], "is_a": "ChemicalMixture", "slots": [ "id", @@ -26091,6 +25998,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularMixture", "@type": "ClassDefinition" }, { @@ -26124,9 +26032,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ComplexMolecularMixture" - ], "is_a": "ChemicalMixture", "slots": [ "id", @@ -26152,6 +26057,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ComplexMolecularMixture", "@type": "ClassDefinition" }, { @@ -26163,9 +26069,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/BiologicalProcessOrActivity", "description": "Either an individual molecular activity, or a collection of causally connected molecular activities in a biological system.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BiologicalProcessOrActivity" - ], "is_a": "BiologicalEntity", "mixins": [ "Occurrent", @@ -26191,6 +26094,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BiologicalProcessOrActivity", "@type": "ClassDefinition" }, { @@ -26219,7 +26123,6 @@ "reaction" ], "exact_mappings": [ - "biolink:MolecularActivity", "GO:0003674", "STY:T044" ], @@ -26251,6 +26154,7 @@ "molecular_activity_enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularActivity", "@type": "ClassDefinition" }, { @@ -26266,7 +26170,6 @@ "description": "One or more causally connected executions of molecular functions", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:BiologicalProcess", "GO:0008150", "SIO:000006", "WIKIDATA:Q2996394" @@ -26299,6 +26202,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BiologicalProcess", "@type": "ClassDefinition" }, { @@ -26319,7 +26223,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Pathway", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Pathway", "PW:0000001", "WIKIDATA:Q4915012" ], @@ -26351,6 +26254,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Pathway", "@type": "ClassDefinition" }, { @@ -26365,7 +26269,6 @@ "physiology" ], "exact_mappings": [ - "biolink:PhysiologicalProcess", "STY:T039", "WIKIDATA:Q30892994" ], @@ -26399,6 +26302,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhysiologicalProcess", "@type": "ClassDefinition" }, { @@ -26406,7 +26310,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Behavior", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Behavior", "GO:0007610", "STY:T053" ], @@ -26440,6 +26343,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Behavior", "@type": "ClassDefinition" }, { @@ -26475,7 +26379,6 @@ "description": "A chemical entity (often a mixture) processed for consumption for nutritional, medical or technical use. Is a material entity that is created or changed during material processing.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ProcessedMaterial", "OBI:0000047" ], "is_a": "ChemicalMixture", @@ -26503,6 +26406,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProcessedMaterial", "@type": "ClassDefinition" }, { @@ -26543,7 +26447,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Drug", "WIKIDATA:Q12140", "CHEBI:23888", "STY:T200", @@ -26584,6 +26487,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Drug", "@type": "ClassDefinition" }, { @@ -26617,9 +26521,6 @@ ], "definition_uri": "https://w3id.org/biolink/vocab/EnvironmentalFoodContaminant", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EnvironmentalFoodContaminant" - ], "related_mappings": [ "CHEBI:78299" ], @@ -26644,6 +26545,7 @@ "has_chemical_role" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EnvironmentalFoodContaminant", "@type": "ClassDefinition" }, { @@ -26677,9 +26579,6 @@ ], "definition_uri": "https://w3id.org/biolink/vocab/FoodAdditive", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FoodAdditive" - ], "related_mappings": [ "CHEBI:64047" ], @@ -26704,6 +26603,7 @@ "has_chemical_role" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FoodAdditive", "@type": "ClassDefinition" }, { @@ -26739,9 +26639,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Food", "description": "A substance consumed by a living organism as a source of nutrition", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Food" - ], "is_a": "ChemicalMixture", "slots": [ "id", @@ -26767,6 +26664,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Food", "@type": "ClassDefinition" }, { @@ -26775,7 +26673,6 @@ "description": "describes a characteristic of an organismal entity.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:OrganismAttribute", "STY:T032" ], "is_a": "Attribute", @@ -26797,6 +26694,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismAttribute", "@type": "ClassDefinition" }, { @@ -26816,9 +26714,6 @@ "mappings": [ "PATO:0000001" ], - "exact_mappings": [ - "biolink:PhenotypicQuality" - ], "is_a": "OrganismAttribute", "slots": [ "id", @@ -26838,6 +26733,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicQuality", "@type": "ClassDefinition" }, { @@ -26854,7 +26750,6 @@ "inheritance" ], "exact_mappings": [ - "biolink:GeneticInheritance", "HP:0000005", "GENO:0000141", "NCIT:C45827" @@ -26880,6 +26775,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneticInheritance", "@type": "ClassDefinition" }, { @@ -26888,7 +26784,6 @@ "description": "A named entity that is either a part of an organism, a whole organism, population or clade of organisms, excluding chemical entities", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:OrganismalEntity", "WIKIDATA:Q7239", "UMLSSG:LIVB", "CARO:0001010" @@ -26918,6 +26813,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismalEntity", "@type": "ClassDefinition" }, { @@ -26926,7 +26822,6 @@ "description": "A member of a group of unicellular microorganisms lacking a nuclear membrane, that reproduce by binary fission and are often motile.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Bacterium", "NCBITaxon:1869227", "STY:T007" ], @@ -26948,6 +26843,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Bacterium", "@type": "ClassDefinition" }, { @@ -26956,7 +26852,6 @@ "description": "A virus is a microorganism that replicates itself as a microRNA and infects the host cell.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Virus", "NCBITaxon:10239", "STY:T005" ], @@ -26981,6 +26876,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Virus", "@type": "ClassDefinition" }, { @@ -26989,7 +26885,6 @@ "description": "", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:CellularOrganism", "NCBITaxon:131567" ], "is_a": "OrganismalEntity", @@ -27013,6 +26908,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellularOrganism", "@type": "ClassDefinition" }, { @@ -27021,7 +26917,6 @@ "description": "A member of the class Mammalia, a clade of endothermic amniotes distinguished from reptiles and birds by the possession of hair, three middle ear bones, mammary glands, and a neocortex", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Mammal", "NCBITaxon:40674", "STY:T015", "NCIT:C14234", @@ -27048,6 +26943,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Mammal", "@type": "ClassDefinition" }, { @@ -27056,7 +26952,6 @@ "description": "A member of the the species Homo sapiens.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Human", "STY:T016", "NCBITaxon:9606", "SIO:000485", @@ -27083,6 +26978,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Human", "@type": "ClassDefinition" }, { @@ -27091,7 +26987,6 @@ "description": "", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Plant", "NCIT:C14258", "STY:T002", "PO:0000003", @@ -27115,6 +27010,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Plant", "@type": "ClassDefinition" }, { @@ -27123,7 +27019,6 @@ "description": "An animal lacking a vertebral column. This group consists of 98% of all animal species.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Invertebrate", "NCIT:C14228", "OMIT:0008565", "FOODON:00002452", @@ -27150,6 +27045,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Invertebrate", "@type": "ClassDefinition" }, { @@ -27158,7 +27054,6 @@ "description": "A sub-phylum of animals consisting of those having a bony or cartilaginous vertebral column.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Vertebrate", "STY:T010", "NCBITaxon:7742", "OMIT:0015545" @@ -27184,6 +27079,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Vertebrate", "@type": "ClassDefinition" }, { @@ -27192,7 +27088,6 @@ "description": "A kingdom of eukaryotic, heterotrophic organisms that live as saprobes or parasites, including mushrooms, yeasts, smuts, molds, etc. They reproduce either sexually or asexually, and have life cycles that range from simple to complex. Filamentous fungi refer to those that grow as multicellular colonies (mushrooms and molds).", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Fungus", "STY:T004", "NCIT:C14209", "FOODON:03411261" @@ -27219,6 +27114,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Fungus", "@type": "ClassDefinition" }, { @@ -27238,7 +27134,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:LifeStage", "UBERON:0000105" ], "narrow_mappings": [ @@ -27262,6 +27157,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/LifeStage", "@type": "ClassDefinition" }, { @@ -27276,7 +27172,6 @@ "organism" ], "exact_mappings": [ - "biolink:IndividualOrganism", "SIO:010000", "STY:T001" ], @@ -27305,6 +27200,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/IndividualOrganism", "@type": "ClassDefinition" }, { @@ -27326,7 +27222,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PopulationOfIndividualOrganisms", "PCO:0000001", "SIO:001061", "STY:T098", @@ -27353,6 +27248,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PopulationOfIndividualOrganisms", "@type": "ClassDefinition" }, { @@ -27363,9 +27259,6 @@ "aliases": [ "phenome" ], - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeature" - ], "narrow_mappings": [ "STY:T033" ], @@ -27387,6 +27280,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeature", "union_of": [ "Disease", "PhenotypicFeature" @@ -27428,7 +27322,6 @@ "medical condition" ], "exact_mappings": [ - "biolink:Disease", "MONDO:0000001", "DOID:4", "NCIT:C2991", @@ -27464,6 +27357,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Disease", "@type": "ClassDefinition" }, { @@ -27507,7 +27401,6 @@ "endophenotype" ], "exact_mappings": [ - "biolink:PhenotypicFeature", "UPHENO:0001001", "SIO:010056", "WIKIDATA:Q104053", @@ -27553,6 +27446,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicFeature", "@type": "ClassDefinition" }, { @@ -27561,7 +27455,6 @@ "description": "A phenotypic feature which is behavioral in nature.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:BehavioralFeature", "NBO:0000243" ], "is_a": "PhenotypicFeature", @@ -27582,6 +27475,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BehavioralFeature", "@type": "ClassDefinition" }, { @@ -27605,7 +27499,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:AnatomicalEntity", "UBERON:0001062", "WIKIDATA:Q4936952", "UMLSSG:ANAT", @@ -27651,6 +27544,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AnatomicalEntity", "@type": "ClassDefinition" }, { @@ -27672,7 +27566,6 @@ "cell part" ], "exact_mappings": [ - "biolink:CellularComponent", "GO:0005575", "SIO:001400", "WIKIDATA:Q5058355", @@ -27699,6 +27592,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellularComponent", "@type": "ClassDefinition" }, { @@ -27716,7 +27610,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Cell", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Cell", "GO:0005623", "CL:0000000", "SIO:010001", @@ -27742,6 +27635,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Cell", "@type": "ClassDefinition" }, { @@ -27752,7 +27646,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CellLine", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:CellLine", "CLO:0000031" ], "is_a": "OrganismalEntity", @@ -27776,6 +27669,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellLine", "@type": "ClassDefinition" }, { @@ -27795,7 +27689,6 @@ "organ" ], "exact_mappings": [ - "biolink:GrossAnatomicalStructure", "UBERON:0010000", "WIKIDATA:Q4936952" ], @@ -27822,6 +27715,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GrossAnatomicalStructure", "@type": "ClassDefinition" }, { @@ -27829,11 +27723,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrGeneOrGeneProduct", "description": "A union of chemical entities and children, and gene or gene product. This mixin is helpful to use when searching across chemical entities that must include genes and their children as chemical entities.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityOrGeneOrGeneProduct" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrGeneOrGeneProduct", "@type": "ClassDefinition" }, { @@ -27841,11 +27733,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrProteinOrPolypeptide", "description": "A union of chemical entities and children, and protein and polypeptide. This mixin is helpful to use when searching across chemical entities that must include genes and their children as chemical entities.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityOrProteinOrPolypeptide" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrProteinOrPolypeptide", "@type": "ClassDefinition" }, { @@ -27853,14 +27743,12 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineMixin", "description": "A union of gene locus, gene product, and macromolecular complex. These are the basic units of function in a cell. They either carry out individual biological activities, or they encode molecules which do this.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineMixin" - ], "mixin": true, "slots": [ "macromolecular_machine_mixin_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineMixin", "@type": "ClassDefinition" }, { @@ -27872,15 +27760,13 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneOrGeneProduct", "description": "A union of gene loci or gene products. Frequently an identifier for one will be used as proxy for another", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneOrGeneProduct" - ], "is_a": "MacromolecularMachineMixin", "mixin": true, "slots": [ "macromolecular_machine_mixin_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneOrGeneProduct", "@type": "ClassDefinition" }, { @@ -27913,7 +27799,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Gene", "SO:0000704", "SIO:010035", "WIKIDATA:Q7187", @@ -27952,6 +27837,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Gene", "@type": "ClassDefinition" }, { @@ -27965,7 +27851,6 @@ "description": "The functional molecular product of a single gene locus. Gene products are either proteins or functional RNA molecules.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GeneProductMixin", "WIKIDATA:Q424689", "GENO:0000907", "NCIT:C26548" @@ -27978,6 +27863,7 @@ "xref" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneProductMixin", "@type": "ClassDefinition" }, { @@ -27985,9 +27871,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneProductIsoformMixin", "description": "This is an abstract class that can be mixed in with different kinds of gene products to indicate that the gene product is intended to represent a specific isoform rather than a canonical or reference or generic product. The designation of canonical or reference may be arbitrary, or it may represent the superclass of all isoforms.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneProductIsoformMixin" - ], "is_a": "GeneProductMixin", "mixin": true, "slots": [ @@ -27996,6 +27879,7 @@ "xref" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneProductIsoformMixin", "@type": "ClassDefinition" }, { @@ -28014,7 +27898,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:MacromolecularComplex", "GO:0032991", "WIKIDATA:Q22325163" ], @@ -28039,6 +27922,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularComplex", "@type": "ClassDefinition" }, { @@ -28046,9 +27930,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/NucleosomeModification", "description": "A chemical modification of a histone protein within a nucleosome octomer or a substitution of a histone with a variant histone isoform. e.g. Histone 4 Lysine 20 methylation (H4K20me), histone variant H2AZ substituting H2A.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:NucleosomeModification" - ], "is_a": "BiologicalEntity", "mixins": [ "GeneProductIsoformMixin", @@ -28073,6 +27954,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NucleosomeModification", "@type": "ClassDefinition" }, { @@ -28084,7 +27966,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Genome", "SO:0001026", "SIO:000984", "WIKIDATA:Q7020" @@ -28116,6 +27997,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Genome", "@type": "ClassDefinition" }, { @@ -28124,7 +28006,6 @@ "description": "A region of the transcript sequence within a gene which is not removed from the primary RNA transcript by RNA splicing.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Exon", "SO:0000147", "SIO:010445", "WIKIDATA:Q373027" @@ -28147,6 +28028,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Exon", "@type": "ClassDefinition" }, { @@ -28162,7 +28044,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Transcript", "SO:0000673", "SIO:010450", "WIKIDATA:Q7243183", @@ -28186,6 +28067,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Transcript", "@type": "ClassDefinition" }, { @@ -28193,7 +28075,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CodingSequence", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:CodingSequence", "SO:0000316", "SIO:001390" ], @@ -28219,6 +28100,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CodingSequence", "@type": "ClassDefinition" }, { @@ -28239,9 +28121,6 @@ "aliases": [ "amino acid entity" ], - "exact_mappings": [ - "biolink:Polypeptide" - ], "narrow_mappings": [ "SO:0000104", "STY:T116", @@ -28269,6 +28148,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Polypeptide", "@type": "ClassDefinition" }, { @@ -28286,7 +28166,6 @@ "description": "A gene product that is composed of a chain of amino acid sequences and is produced by ribosome-mediated translation of mRNA", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Protein", "PR:000000001", "SIO:010043", "WIKIDATA:Q8054" @@ -28319,6 +28198,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Protein", "@type": "ClassDefinition" }, { @@ -28335,9 +28215,6 @@ "aliases": [ "proteoform" ], - "exact_mappings": [ - "biolink:ProteinIsoform" - ], "is_a": "Protein", "mixins": [ "GeneProductIsoformMixin" @@ -28359,6 +28236,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProteinIsoform", "@type": "ClassDefinition" }, { @@ -28367,7 +28245,6 @@ "description": "A conserved part of protein sequence and (tertiary) structure that can evolve, function, and exist independently of the rest of the protein chain. Protein domains maintain their structure and function independently of the proteins in which they are found. e.g. an SH3 domain.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ProteinDomain", "NCIT:C13379", "SIO:001379", "UMLS:C1514562" @@ -28395,6 +28272,7 @@ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProteinDomain", "@type": "ClassDefinition" }, { @@ -28402,9 +28280,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PosttranslationalModification", "description": "A chemical modification of a polypeptide or protein that occurs after translation. e.g. polypeptide cleavage to form separate proteins, methylation or acetylation of histone tail amino acids, protein ubiquitination.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PosttranslationalModification" - ], "is_a": "BiologicalEntity", "mixins": [ "GeneProductIsoformMixin" @@ -28426,6 +28301,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PosttranslationalModification", "@type": "ClassDefinition" }, { @@ -28433,7 +28309,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ProteinFamily", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ProteinFamily", "NCIT:C26004", "WIKIDATA:Q2278983" ], @@ -28465,6 +28340,7 @@ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProteinFamily", "@type": "ClassDefinition" }, { @@ -28475,9 +28351,6 @@ "aliases": [ "consensus sequence" ], - "exact_mappings": [ - "biolink:NucleicAcidSequenceMotif" - ], "is_a": "BiologicalEntity", "slots": [ "id", @@ -28496,6 +28369,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NucleicAcidSequenceMotif", "@type": "ClassDefinition" }, { @@ -28506,7 +28380,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/RNAProduct", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:RNAProduct", "CHEBI:33697", "WIKIDATA:Q11053" ], @@ -28531,6 +28404,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RNAProduct", "@type": "ClassDefinition" }, { @@ -28541,9 +28415,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/RNAProductIsoform", "description": "Represents a protein that is a specific isoform of the canonical or reference RNA", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RNAProductIsoform" - ], "is_a": "RNAProduct", "mixins": [ "GeneProductIsoformMixin" @@ -28565,6 +28436,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RNAProductIsoform", "@type": "ClassDefinition" }, { @@ -28577,7 +28449,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/NoncodingRNAProduct", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:NoncodingRNAProduct", "SO:0000655", "SIO:001235" ], @@ -28599,6 +28470,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NoncodingRNAProduct", "@type": "ClassDefinition" }, { @@ -28614,7 +28486,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:MicroRNA", "SO:0000276", "SIO:001397", "WIKIDATA:Q310899" @@ -28637,6 +28508,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MicroRNA", "@type": "ClassDefinition" }, { @@ -28657,7 +28529,6 @@ "RNAi" ], "exact_mappings": [ - "biolink:SiRNA", "SO:0000646", "WIKIDATA:Q203221" ], @@ -28679,6 +28550,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SiRNA", "@type": "ClassDefinition" }, { @@ -28686,14 +28558,12 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneGroupingMixin", "description": "any grouping of multiple genes or gene products", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneGroupingMixin" - ], "mixin": true, "slots": [ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneGroupingMixin", "@type": "ClassDefinition" }, { @@ -28731,7 +28601,6 @@ "protein family" ], "exact_mappings": [ - "biolink:GeneFamily", "NCIT:C26004", "WIKIDATA:Q2278983" ], @@ -28763,6 +28632,7 @@ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneFamily", "@type": "ClassDefinition" }, { @@ -28770,7 +28640,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Zygosity", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Zygosity", "GENO:0000133" ], "is_a": "Attribute", @@ -28792,6 +28661,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Zygosity", "@type": "ClassDefinition" }, { @@ -28810,7 +28680,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Genotype", "GENO:0000536", "SIO:001079" ], @@ -28839,6 +28708,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Genotype", "@type": "ClassDefinition" }, { @@ -28847,7 +28717,6 @@ "description": "A set of zero or more Alleles on a single instance of a Sequence[VMC]", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Haplotype", "GENO:0000871", "SO:0001024", "VMC:Haplotype" @@ -28876,6 +28745,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Haplotype", "@type": "ClassDefinition" }, { @@ -28927,7 +28797,6 @@ "allele" ], "exact_mappings": [ - "biolink:SequenceVariant", "WIKIDATA:Q15304597" ], "close_mappings": [ @@ -28963,6 +28832,7 @@ "sequence_variant_id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SequenceVariant", "@type": "ClassDefinition" }, { @@ -28976,7 +28846,6 @@ "snp" ], "exact_mappings": [ - "biolink:Snv", "SO:0001483" ], "is_a": "SequenceVariant", @@ -28999,6 +28868,7 @@ "sequence_variant_id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Snv", "@type": "ClassDefinition" }, { @@ -29013,7 +28883,6 @@ "sequence targeting reagent" ], "exact_mappings": [ - "biolink:ReagentTargetedGene", "GENO:0000504" ], "is_a": "BiologicalEntity", @@ -29040,6 +28909,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ReagentTargetedGene", "@type": "ClassDefinition" }, { @@ -29048,7 +28918,6 @@ "description": "Attributes relating to a clinical manifestation", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ClinicalAttribute", "STY:T201" ], "is_a": "Attribute", @@ -29070,6 +28939,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalAttribute", "@type": "ClassDefinition" }, { @@ -29078,7 +28948,6 @@ "description": "A clinical measurement is a special kind of attribute which results from a laboratory observation from a subject individual or sample. Measurements can be connected to their subject by the 'has attribute' slot.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ClinicalMeasurement", "EFO:0001444" ], "is_a": "ClinicalAttribute", @@ -29100,6 +28969,7 @@ "clinical_measurement_has_attribute_type" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalMeasurement", "@type": "ClassDefinition" }, { @@ -29107,9 +28977,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ClinicalModifier", "description": "Used to characterize and specify the phenotypic abnormalities defined in the phenotypic abnormality sub-ontology, with respect to severity, laterality, and other aspects", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalModifier" - ], "is_a": "ClinicalAttribute", "slots": [ "id", @@ -29129,6 +28996,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalModifier", "@type": "ClassDefinition" }, { @@ -29137,7 +29005,6 @@ "description": "The course a disease typically takes from its onset, progression in time, and eventual resolution or death of the affected individual", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ClinicalCourse", "HP:0031797" ], "is_a": "ClinicalAttribute", @@ -29159,6 +29026,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalCourse", "@type": "ClassDefinition" }, { @@ -29170,7 +29038,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Onset", "HP:0003674" ], "is_a": "ClinicalCourse", @@ -29192,6 +29059,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Onset", "@type": "ClassDefinition" }, { @@ -29199,9 +29067,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ClinicalEntity", "description": "Any entity or process that exists in the clinical domain and outside the biological realm. Diseases are placed under biological entities", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalEntity" - ], "is_a": "NamedThing", "slots": [ "id", @@ -29218,15 +29083,13 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalEntity", "@type": "ClassDefinition" }, { "name": "ClinicalTrial", "definition_uri": "https://w3id.org/biolink/vocab/ClinicalTrial", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalTrial" - ], "is_a": "ClinicalEntity", "slots": [ "id", @@ -29243,15 +29106,13 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalTrial", "@type": "ClassDefinition" }, { "name": "ClinicalIntervention", "definition_uri": "https://w3id.org/biolink/vocab/ClinicalIntervention", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalIntervention" - ], "is_a": "ClinicalEntity", "slots": [ "id", @@ -29268,6 +29129,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalIntervention", "@type": "ClassDefinition" }, { @@ -29280,9 +29142,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ClinicalFinding", "description": "this category is currently considered broad enough to tag clinical lab measurements and other biological attributes taken as 'clinical traits' with some statistical score, for example, a p value in genetic associations.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalFinding" - ], "is_a": "PhenotypicFeature", "slots": [ "id", @@ -29301,6 +29160,7 @@ "clinical_finding_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalFinding", "@type": "ClassDefinition" }, { @@ -29308,7 +29168,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Hospitalization", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Hospitalization", "SNOMEDCT:32485007", "WIKIDATA:Q3140971" ], @@ -29328,6 +29187,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Hospitalization", "@type": "ClassDefinition" }, { @@ -29335,9 +29195,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/SocioeconomicAttribute", "description": "Attributes relating to a socioeconomic manifestation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SocioeconomicAttribute" - ], "is_a": "Attribute", "slots": [ "id", @@ -29357,6 +29214,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SocioeconomicAttribute", "@type": "ClassDefinition" }, { @@ -29368,9 +29226,6 @@ "patient", "proband" ], - "exact_mappings": [ - "biolink:Case" - ], "is_a": "IndividualOrganism", "mixins": [ "SubjectOfInvestigation" @@ -29392,6 +29247,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Case", "@type": "ClassDefinition" }, { @@ -29400,7 +29256,6 @@ "description": "A group of people banded together or treated as a group who share common characteristics. A cohort 'study' is a particular form of longitudinal study that samples a cohort, performing a cross-section at intervals through time.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Cohort", "WIKIDATA:Q1303415" ], "narrow_mappings": [ @@ -29430,6 +29285,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Cohort", "@type": "ClassDefinition" }, { @@ -29445,7 +29301,6 @@ "experimental condition" ], "exact_mappings": [ - "biolink:ExposureEvent", "XCO:0000000" ], "is_a": "OntologyClass", @@ -29455,6 +29310,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ExposureEvent", "@type": "ClassDefinition" }, { @@ -29462,9 +29318,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenomicBackgroundExposure", "description": "A genomic background exposure is where an individual's specific genomic background of genes, sequence variants or other pre-existing genomic conditions constitute a kind of 'exposure' to the organism, leading to or influencing an outcome.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenomicBackgroundExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent", @@ -29497,6 +29350,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenomicBackgroundExposure", "@type": "ClassDefinition" }, { @@ -29505,7 +29359,6 @@ "description": "A pathological (abnormal) structure or process.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PathologicalEntityMixin", "MPATH:0" ], "narrow_mappings": [ @@ -29513,6 +29366,7 @@ ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalEntityMixin", "@type": "ClassDefinition" }, { @@ -29521,7 +29375,6 @@ "description": "A biologic function or a process having an abnormal or deleterious effect at the subcellular, cellular, multicellular, or organismal level.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PathologicalProcess", "OBI:1110122", "NCIT:C16956", "MPATH:596" @@ -29556,6 +29409,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalProcess", "@type": "ClassDefinition" }, { @@ -29563,9 +29417,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathologicalProcessExposure", "description": "A pathological process, when viewed as an exposure, representing a precondition, leading to or influencing an outcome, e.g. autoimmunity leading to disease.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathologicalProcessExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -29589,6 +29440,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalProcessExposure", "@type": "ClassDefinition" }, { @@ -29597,7 +29449,6 @@ "description": "An anatomical structure with the potential of have an abnormal or deleterious effect at the subcellular, cellular, multicellular, or organismal level.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PathologicalAnatomicalStructure", "MPATH:603" ], "is_a": "AnatomicalEntity", @@ -29621,6 +29472,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalStructure", "@type": "ClassDefinition" }, { @@ -29628,9 +29480,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalExposure", "description": "An abnormal anatomical structure, when viewed as an exposure, representing an precondition, leading to or influencing an outcome, e.g. thrombosis leading to an ischemic disease outcome.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathologicalAnatomicalExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -29654,6 +29503,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalExposure", "@type": "ClassDefinition" }, { @@ -29661,9 +29511,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureExposure", "description": "A disease or phenotypic feature state, when viewed as an exposure, represents an precondition, leading to or influencing an outcome, e.g. HIV predisposing an individual to infections; a relative deficiency of skin pigmentation predisposing an individual to skin cancer.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent", @@ -29688,6 +29535,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureExposure", "@type": "ClassDefinition" }, { @@ -29696,7 +29544,6 @@ "description": "A chemical exposure is an intake of a particular chemical entity.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalExposure", "ECTO:9000000", "SIO:001399" ], @@ -29723,6 +29570,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalExposure", "@type": "ClassDefinition" }, { @@ -29730,9 +29578,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ComplexChemicalExposure", "description": "A complex chemical exposure is an intake of a chemical mixture (e.g. gasoline), other than a drug.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ComplexChemicalExposure" - ], "is_a": "Attribute", "slots": [ "id", @@ -29752,6 +29597,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ComplexChemicalExposure", "@type": "ClassDefinition" }, { @@ -29765,7 +29611,6 @@ "medication intake" ], "exact_mappings": [ - "biolink:DrugExposure", "ECTO:0000509" ], "broad_mappings": [ @@ -29794,6 +29639,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugExposure", "@type": "ClassDefinition" }, { @@ -29801,9 +29647,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DrugToGeneInteractionExposure", "description": "drug to gene interaction exposure is a drug exposure is where the interactions of the drug with specific genes are known to constitute an 'exposure' to the organism, leading to or influencing an outcome.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DrugToGeneInteractionExposure" - ], "is_a": "DrugExposure", "mixins": [ "GeneGroupingMixin" @@ -29828,6 +29671,7 @@ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugToGeneInteractionExposure", "@type": "ClassDefinition" }, { @@ -29840,7 +29684,6 @@ "medical intervention" ], "exact_mappings": [ - "biolink:Treatment", "OGMS:0000090", "SIO:001398" ], @@ -29871,6 +29714,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Treatment", "@type": "ClassDefinition" }, { @@ -29882,9 +29726,6 @@ "viral exposure", "bacterial exposure" ], - "exact_mappings": [ - "biolink:BioticExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -29908,6 +29749,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BioticExposure", "@type": "ClassDefinition" }, { @@ -29915,9 +29757,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeographicExposure", "description": "A geographic exposure is a factor relating to geographic proximity to some impactful entity.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeographicExposure" - ], "close_mappings": [ "dcid:GeologicalEvent" ], @@ -29960,6 +29799,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeographicExposure", "@type": "ClassDefinition" }, { @@ -29967,9 +29807,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EnvironmentalExposure", "description": "A environmental exposure is a factor relating to abiotic processes in the environment including sunlight (UV-B), atmospheric (heat, cold, general pollution) and water-born contaminants.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EnvironmentalExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -29993,6 +29830,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EnvironmentalExposure", "@type": "ClassDefinition" }, { @@ -30000,9 +29838,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/BehavioralExposure", "description": "A behavioral exposure is a factor relating to behavior impacting an individual.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BehavioralExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -30026,6 +29861,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BehavioralExposure", "@type": "ClassDefinition" }, { @@ -30033,9 +29869,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/SocioeconomicExposure", "description": "A socioeconomic exposure is a factor relating to social and financial status of an affected individual (e.g. poverty).", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SocioeconomicExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -30059,6 +29892,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SocioeconomicExposure", "@type": "ClassDefinition" }, { @@ -30066,11 +29900,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/Outcome", "description": "An entity that has the role of being the consequence of an exposure event. This is an abstract mixin grouping of various categories of possible biological or non-biological (e.g. clinical) outcomes.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Outcome" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Outcome", "@type": "ClassDefinition" }, { @@ -30078,13 +29910,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathologicalProcessOutcome", "description": "An outcome resulting from an exposure event which is the manifestation of a pathological process.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathologicalProcessOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalProcessOutcome", "@type": "ClassDefinition" }, { @@ -30092,13 +29922,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalOutcome", "description": "An outcome resulting from an exposure event which is the manifestation of an abnormal anatomical structure.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathologicalAnatomicalOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalOutcome", "@type": "ClassDefinition" }, { @@ -30106,13 +29934,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureOutcome", "description": "Physiological outcomes resulting from an exposure event which is the manifestation of a disease or other characteristic phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureOutcome", "@type": "ClassDefinition" }, { @@ -30120,13 +29946,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/BehavioralOutcome", "description": "An outcome resulting from an exposure event which is the manifestation of human behavior.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BehavioralOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BehavioralOutcome", "@type": "ClassDefinition" }, { @@ -30134,13 +29958,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/HospitalizationOutcome", "description": "An outcome resulting from an exposure event which is the increased manifestation of acute (e.g. emergency room visit) or chronic (inpatient) hospitalization.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:HospitalizationOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/HospitalizationOutcome", "@type": "ClassDefinition" }, { @@ -30148,13 +29970,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/MortalityOutcome", "description": "An outcome of death from resulting from an exposure event.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MortalityOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MortalityOutcome", "@type": "ClassDefinition" }, { @@ -30162,9 +29982,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EpidemiologicalOutcome", "description": "An epidemiological outcome, such as societal disease burden, resulting from an exposure event.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EpidemiologicalOutcome" - ], "related_mappings": [ "NCIT:C19291" ], @@ -30172,6 +29989,7 @@ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EpidemiologicalOutcome", "@type": "ClassDefinition" }, { @@ -30179,13 +29997,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/SocioeconomicOutcome", "description": "An general social or economic outcome, such as healthcare costs, utilization, etc., resulting from an exposure event", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SocioeconomicOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SocioeconomicOutcome", "@type": "ClassDefinition" }, { @@ -30197,7 +30013,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Association", "OBAN:association", "rdf:Statement", "owl:Axiom" @@ -30245,15 +30060,13 @@ "association_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Association", "@type": "ClassDefinition" }, { "name": "ChemicalEntityAssessesNamedThingAssociation", "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityAssessesNamedThingAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityAssessesNamedThingAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30297,6 +30110,7 @@ "chemical_entity_assesses_named_thing_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityAssessesNamedThingAssociation", "@type": "ClassDefinition" }, { @@ -30304,9 +30118,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ContributorAssociation", "description": "Any association between an entity (such as a publication) and various agents that contribute to its realisation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ContributorAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30350,6 +30161,7 @@ "contributor_association_qualifiers" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ContributorAssociation", "defining_slots": [ "subject", "predicate", @@ -30362,9 +30174,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToGenotypePartAssociation", "description": "Any association between one genotype and a genotypic entity that is a sub-component of it", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToGenotypePartAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30408,6 +30217,7 @@ "genotype_to_genotype_part_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToGenotypePartAssociation", "defining_slots": [ "subject", "object" @@ -30419,9 +30229,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToGeneAssociation", "description": "Any association between a genotype and a gene. The genotype have have multiple variants in that gene or a single one. There is no assumption of cardinality", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToGeneAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30465,6 +30272,7 @@ "genotype_to_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToGeneAssociation", "defining_slots": [ "subject", "object" @@ -30476,9 +30284,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToVariantAssociation", "description": "Any association between a genotype and a sequence variant.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToVariantAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30522,6 +30327,7 @@ "genotype_to_variant_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToVariantAssociation", "defining_slots": [ "subject", "object" @@ -30536,9 +30342,6 @@ "aliases": [ "molecular or genetic interaction" ], - "exact_mappings": [ - "biolink:GeneToGeneAssociation" - ], "is_a": "Association", "abstract": true, "slots": [ @@ -30583,6 +30386,7 @@ "gene_to_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneAssociation", "defining_slots": [ "subject", "object" @@ -30594,9 +30398,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToGeneHomologyAssociation", "description": "A homology association between two genes. May be orthology (in which case the species of subject and object should differ) or paralogy (in which case the species may be the same)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToGeneHomologyAssociation" - ], "is_a": "GeneToGeneAssociation", "slots": [ "id", @@ -30640,6 +30441,7 @@ "gene_to_gene_homology_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneHomologyAssociation", "defining_slots": [ "subject", "predicate", @@ -30652,9 +30454,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToGeneFamilyAssociation", "description": "Set membership of a gene in a family of genes related by common evolutionary ancestry usually inferred by sequence comparisons. The genes in a given family generally share common sequence motifs which generally map onto shared gene product structure-function relationships.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToGeneFamilyAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30698,6 +30497,7 @@ "gene_to_gene_family_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneFamilyAssociation", "defining_slots": [ "subject", "predicate", @@ -30710,9 +30510,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneExpressionMixin", "description": "Observed gene expression intensity, context (site, stage) and associated phenotypic status within which the expression occurs.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneExpressionMixin" - ], "mixin": true, "slots": [ "gene_expression_mixin_quantifier_qualifier", @@ -30721,6 +30518,7 @@ "phenotypic_state" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneExpressionMixin", "@type": "ClassDefinition" }, { @@ -30728,9 +30526,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToGeneCoexpressionAssociation", "description": "Indicates that two genes are co-expressed, generally under the same conditions.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToGeneCoexpressionAssociation" - ], "is_a": "GeneToGeneAssociation", "mixins": [ "GeneExpressionMixin" @@ -30781,6 +30576,7 @@ "phenotypic_state" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneCoexpressionAssociation", "defining_slots": [ "subject", "predicate", @@ -30793,9 +30589,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PairwiseGeneToGeneInteraction", "description": "An interaction between two genes or two gene products. May be physical (e.g. protein binding) or genetic (between genes). May be symmetric (e.g. protein interaction) or directed (e.g. phosphorylation)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PairwiseGeneToGeneInteraction" - ], "narrow_mappings": [ "dcid:ProteinProteinInteraction" ], @@ -30842,6 +30635,7 @@ "pairwise_gene_to_gene_interaction_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PairwiseGeneToGeneInteraction", "defining_slots": [ "subject", "predicate", @@ -30854,9 +30648,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PairwiseMolecularInteraction", "description": "An interaction at the molecular level between two physical entities", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PairwiseMolecularInteraction" - ], "is_a": "PairwiseGeneToGeneInteraction", "slots": [ "iri", @@ -30901,6 +30692,7 @@ "pairwise_molecular_interaction_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PairwiseMolecularInteraction", "defining_slots": [ "subject", "predicate", @@ -30913,9 +30705,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CellLineToEntityAssociationMixin", "description": "An relationship between a cell line and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CellLineToEntityAssociationMixin" - ], "mixin": true, "slots": [ "cell_line_to_entity_association_mixin_subject", @@ -30923,6 +30712,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellLineToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -30933,9 +30723,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CellLineToDiseaseOrPhenotypicFeatureAssociation", "description": "An relationship between a cell line and a disease or a phenotype, where the cell line is derived from an individual with that disease or phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CellLineToDiseaseOrPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "CellLineToEntityAssociationMixin", @@ -30983,6 +30770,7 @@ "cell_line_to_disease_or_phenotypic_feature_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellLineToDiseaseOrPhenotypicFeatureAssociation", "@type": "ClassDefinition" }, { @@ -30990,9 +30778,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityToEntityAssociationMixin", "description": "An interaction between a chemical entity and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityToEntityAssociationMixin" - ], "mixin": true, "slots": [ "chemical_entity_to_entity_association_mixin_subject", @@ -31000,6 +30785,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -31010,9 +30796,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DrugToEntityAssociationMixin", "description": "An interaction between a drug and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DrugToEntityAssociationMixin" - ], "is_a": "ChemicalEntityToEntityAssociationMixin", "mixin": true, "slots": [ @@ -31021,6 +30804,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -31031,9 +30815,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalToEntityAssociationMixin", "description": "An interaction between a chemical entity and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalToEntityAssociationMixin" - ], "is_a": "ChemicalEntityToEntityAssociationMixin", "mixin": true, "slots": [ @@ -31042,6 +30823,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -31052,9 +30834,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CaseToEntityAssociationMixin", "description": "An abstract association for use where the case is the subject", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CaseToEntityAssociationMixin" - ], "mixin": true, "slots": [ "case_to_entity_association_mixin_subject", @@ -31062,6 +30841,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CaseToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -31072,9 +30852,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalToChemicalAssociation", "description": "A relationship between two chemical entities. This can encompass actual interactions as well as temporal causal edges, e.g. one chemical converted to another.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalToChemicalAssociation" - ], "is_a": "Association", "mixins": [ "ChemicalToEntityAssociationMixin" @@ -31121,6 +30898,7 @@ "chemical_to_chemical_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToChemicalAssociation", "defining_slots": [ "subject", "object" @@ -31131,9 +30909,6 @@ "name": "ReactionToParticipantAssociation", "definition_uri": "https://w3id.org/biolink/vocab/ReactionToParticipantAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ReactionToParticipantAssociation" - ], "is_a": "ChemicalToChemicalAssociation", "slots": [ "id", @@ -31180,6 +30955,7 @@ "reaction_to_participant_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ReactionToParticipantAssociation", "defining_slots": [ "subject", "predicate", @@ -31191,9 +30967,6 @@ "name": "ReactionToCatalystAssociation", "definition_uri": "https://w3id.org/biolink/vocab/ReactionToCatalystAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ReactionToCatalystAssociation" - ], "is_a": "ReactionToParticipantAssociation", "slots": [ "id", @@ -31240,6 +31013,7 @@ "reaction_to_catalyst_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ReactionToCatalystAssociation", "@type": "ClassDefinition" }, { @@ -31247,9 +31021,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalToChemicalDerivationAssociation", "description": "A causal relationship between two chemical entities, where the subject represents the upstream entity and the object represents the downstream. For any such association there is an implicit reaction: IF R has-input C1 AND R has-output C2 AND R enabled-by P AND R type Reaction THEN C1 derives-into C2 catalyst qualifier P", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalToChemicalDerivationAssociation" - ], "is_a": "ChemicalToChemicalAssociation", "slots": [ "id", @@ -31294,6 +31065,7 @@ "chemical_to_chemical_derivation_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToChemicalDerivationAssociation", "defining_slots": [ "subject", "predicate", @@ -31306,9 +31078,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalToDiseaseOrPhenotypicFeatureAssociation", "description": "An interaction between a chemical entity and a phenotype or disease, where the presence of the chemical gives rise to or exacerbates the phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalToDiseaseOrPhenotypicFeatureAssociation" - ], "narrow_mappings": [ "SIO:000993" ], @@ -31359,6 +31128,7 @@ "chemical_to_disease_or_phenotypic_feature_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToDiseaseOrPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -31370,9 +31140,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation", "description": "This association defines a relationship between a chemical or treatment (or procedure) and a disease or phenotypic feature where the disease or phenotypic feature is a secondary undesirable effect.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "ChemicalToEntityAssociationMixin", @@ -31429,6 +31196,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation", "defining_slots": [ "subject", "predicate", @@ -31441,9 +31209,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation", "description": "This association defines a relationship between a chemical or treatment (or procedure) and a disease or phenotypic feature where the disesae or phenotypic feature is a secondary, typically (but not always) undesirable effect.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation" - ], "is_a": "ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation", "mixins": [ "ChemicalToEntityAssociationMixin", @@ -31499,6 +31264,7 @@ "chemical_or_drug_or_treatment_side_effect_disease_or_phenotypic_feature_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation", "defining_slots": [ "subject", "predicate", @@ -31511,9 +31277,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToPathwayAssociation", "description": "An interaction between a gene or gene product and a biological process or pathway.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToPathwayAssociation" - ], "is_a": "Association", "mixins": [ "GeneToEntityAssociationMixin" @@ -31560,6 +31323,7 @@ "gene_to_pathway_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToPathwayAssociation", "defining_slots": [ "subject", "object" @@ -31571,9 +31335,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MolecularActivityToPathwayAssociation", "description": "Association that holds the relationship between a reaction and the pathway it participates in.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularActivityToPathwayAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31617,6 +31378,7 @@ "molecular_activity_to_pathway_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularActivityToPathwayAssociation", "@type": "ClassDefinition" }, { @@ -31625,7 +31387,6 @@ "description": "An interaction between a chemical entity and a biological process or pathway.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalToPathwayAssociation", "SIO:001250" ], "is_a": "Association", @@ -31674,6 +31435,7 @@ "chemical_to_pathway_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToPathwayAssociation", "defining_slots": [ "subject", "object" @@ -31685,9 +31447,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/NamedThingAssociatedWithLikelihoodOfNamedThingAssociation", "description": "", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:NamedThingAssociatedWithLikelihoodOfNamedThingAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31736,6 +31495,7 @@ "population_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NamedThingAssociatedWithLikelihoodOfNamedThingAssociation", "defining_slots": [ "subject", "subject_aspect_qualifier", @@ -31754,7 +31514,6 @@ "description": "describes a physical interaction between a chemical entity and a gene or gene product. Any biological or chemical effect resulting from such an interaction are out of scope, and covered by the ChemicalAffectsGeneAssociation type (e.g. impact of a chemical on the abundance, activity, structure, etc, of either participant in the interaction)", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalGeneInteractionAssociation", "SIO:001257" ], "is_a": "Association", @@ -31811,6 +31570,7 @@ "chemical_gene_interaction_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalGeneInteractionAssociation", "@type": "ClassDefinition" }, { @@ -31824,9 +31584,6 @@ } ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneRegulatesGeneAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31874,6 +31631,7 @@ "gene_regulates_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneRegulatesGeneAssociation", "@type": "ClassDefinition" }, { @@ -31881,9 +31639,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ProcessRegulatesProcessAssociation", "description": "Describes a regulatory relationship between two genes or gene products.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ProcessRegulatesProcessAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31927,6 +31682,7 @@ "process_regulates_process_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProcessRegulatesProcessAssociation", "@type": "ClassDefinition" }, { @@ -31934,9 +31690,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalAffectsGeneAssociation", "description": "Describes an effect that a chemical has on a gene or gene product (e.g. an impact of on its abundance, activity,localization, processing, expression, etc.)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalAffectsGeneAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31995,6 +31748,7 @@ "chemical_affects_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalAffectsGeneAssociation", "@type": "ClassDefinition" }, { @@ -32008,9 +31762,6 @@ } ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneAffectsChemicalAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -32070,6 +31821,7 @@ "gene_affects_chemical_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneAffectsChemicalAssociation", "@type": "ClassDefinition" }, { @@ -32077,9 +31829,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DrugToGeneAssociation", "description": "An interaction between a drug and a gene or gene product.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DrugToGeneAssociation" - ], "related_mappings": [ "SIO:001257" ], @@ -32129,6 +31878,7 @@ "drug_to_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugToGeneAssociation", "defining_slots": [ "subject", "object" @@ -32140,9 +31890,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MaterialSampleToEntityAssociationMixin", "description": "An association between a material sample and something.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MaterialSampleToEntityAssociationMixin" - ], "mixin": true, "slots": [ "material_sample_to_entity_association_mixin_subject", @@ -32150,6 +31897,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MaterialSampleToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32160,9 +31908,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MaterialSampleDerivationAssociation", "description": "An association between a material sample and the material entity from which it is derived.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MaterialSampleDerivationAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -32206,6 +31951,7 @@ "material_sample_derivation_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MaterialSampleDerivationAssociation", "defining_slots": [ "subject", "predicate" @@ -32217,9 +31963,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MaterialSampleToDiseaseOrPhenotypicFeatureAssociation", "description": "An association between a material sample and a disease or phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MaterialSampleToDiseaseOrPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "MaterialSampleToEntityAssociationMixin", @@ -32267,6 +32010,7 @@ "association_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MaterialSampleToDiseaseOrPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -32277,9 +32021,6 @@ "name": "DiseaseToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/DiseaseToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseToEntityAssociationMixin" - ], "mixin": true, "slots": [ "disease_to_entity_association_mixin_subject", @@ -32287,6 +32028,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32297,9 +32039,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EntityToExposureEventAssociationMixin", "description": "An association between some entity and an exposure event.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToExposureEventAssociationMixin" - ], "mixin": true, "slots": [ "subject", @@ -32307,6 +32046,7 @@ "entity_to_exposure_event_association_mixin_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToExposureEventAssociationMixin", "defining_slots": [ "object" ], @@ -32317,9 +32057,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseToExposureEventAssociation", "description": "An association between an exposure event and a disease.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseToExposureEventAssociation" - ], "is_a": "Association", "mixins": [ "DiseaseToEntityAssociationMixin", @@ -32367,6 +32104,7 @@ "association_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseToExposureEventAssociation", "defining_slots": [ "subject", "object" @@ -32378,9 +32116,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EntityToOutcomeAssociationMixin", "description": "An association between some entity and an outcome", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToOutcomeAssociationMixin" - ], "mixin": true, "slots": [ "subject", @@ -32388,6 +32123,7 @@ "entity_to_outcome_association_mixin_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToOutcomeAssociationMixin", "defining_slots": [ "object" ], @@ -32398,9 +32134,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ExposureEventToOutcomeAssociation", "description": "An association between an exposure event and an outcome.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ExposureEventToOutcomeAssociation" - ], "is_a": "Association", "mixins": [ "EntityToOutcomeAssociationMixin" @@ -32449,6 +32182,7 @@ "temporal_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ExposureEventToOutcomeAssociation", "defining_slots": [ "subject", "object" @@ -32460,9 +32194,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/FrequencyQualifierMixin", "description": "Qualifier for frequency type associations", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FrequencyQualifierMixin" - ], "mixin": true, "slots": [ "frequency_qualifier", @@ -32471,6 +32202,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FrequencyQualifierMixin", "@type": "ClassDefinition" }, { @@ -32478,9 +32210,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EntityToFeatureOrDiseaseQualifiersMixin", "description": "Qualifiers for entity to disease or phenotype associations.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToFeatureOrDiseaseQualifiersMixin" - ], "is_a": "FrequencyQualifierMixin", "mixin": true, "slots": [ @@ -32496,6 +32225,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToFeatureOrDiseaseQualifiersMixin", "@type": "ClassDefinition" }, { @@ -32503,9 +32233,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/FeatureOrDiseaseQualifiersToEntityMixin", "description": "Qualifiers for disease or phenotype to entity associations.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FeatureOrDiseaseQualifiersToEntityMixin" - ], "is_a": "FrequencyQualifierMixin", "mixin": true, "slots": [ @@ -32520,15 +32247,13 @@ "qualified_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FeatureOrDiseaseQualifiersToEntityMixin", "@type": "ClassDefinition" }, { "name": "EntityToPhenotypicFeatureAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/EntityToPhenotypicFeatureAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToPhenotypicFeatureAssociationMixin" - ], "is_a": "EntityToFeatureOrDiseaseQualifiersMixin", "mixin": true, "mixins": [ @@ -32555,6 +32280,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToPhenotypicFeatureAssociationMixin", "defining_slots": [ "subject", "predicate", @@ -32566,9 +32292,6 @@ "name": "PhenotypicFeatureToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhenotypicFeatureToEntityAssociationMixin" - ], "is_a": "FeatureOrDiseaseQualifiersToEntityMixin", "mixin": true, "mixins": [ @@ -32591,6 +32314,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32601,9 +32325,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToPhenotypicFeatureAssociation", "description": "Association between two concept nodes of phenotypic character, qualified by the predicate used. This association may typically be used to specify 'similar_to' or 'member_of' relationships.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhenotypicFeatureToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "PhenotypicFeatureToEntityAssociationMixin", @@ -32666,6 +32387,7 @@ "anatomical_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "predicate", @@ -32681,9 +32403,6 @@ "model_organism_database" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:InformationContentEntityToNamedThingAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -32727,6 +32446,7 @@ "information_content_entity_to_named_thing_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/InformationContentEntityToNamedThingAssociation", "defining_slots": [ "subject", "object" @@ -32738,9 +32458,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseAssociationMixin", "description": "mixin class for any association whose object (target node) is a disease", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToDiseaseAssociationMixin" - ], "is_a": "EntityToFeatureOrDiseaseQualifiersMixin", "mixin": true, "slots": [ @@ -32756,6 +32473,7 @@ "entity_to_disease_association_mixin_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseAssociationMixin", "defining_slots": [ "object" ], @@ -32765,9 +32483,6 @@ "name": "DiseaseOrPhenotypicFeatureToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureToEntityAssociationMixin" - ], "mixin": true, "slots": [ "disease_or_phenotypic_feature_to_entity_association_mixin_subject", @@ -32775,6 +32490,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32785,9 +32501,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToLocationAssociation", "description": "An association between either a disease or a phenotypic feature and an anatomical entity, where the disease/feature manifests in that site.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureToLocationAssociation" - ], "is_a": "Association", "mixins": [ "DiseaseOrPhenotypicFeatureToEntityAssociationMixin" @@ -32834,6 +32547,7 @@ "disease_or_phenotypic_feature_to_location_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToLocationAssociation", "@type": "ClassDefinition" }, { @@ -32841,9 +32555,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToGeneticInheritanceAssociation", "description": "An association between either a disease or a phenotypic feature and its mode of (genetic) inheritance.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureToGeneticInheritanceAssociation" - ], "is_a": "Association", "mixins": [ "DiseaseOrPhenotypicFeatureToEntityAssociationMixin" @@ -32890,15 +32601,13 @@ "disease_or_phenotypic_feature_to_genetic_inheritance_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToGeneticInheritanceAssociation", "@type": "ClassDefinition" }, { "name": "EntityToDiseaseOrPhenotypicFeatureAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseOrPhenotypicFeatureAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToDiseaseOrPhenotypicFeatureAssociationMixin" - ], "mixin": true, "slots": [ "subject", @@ -32906,6 +32615,7 @@ "entity_to_disease_or_phenotypic_feature_association_mixin_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseOrPhenotypicFeatureAssociationMixin", "defining_slots": [ "object" ], @@ -32915,9 +32625,6 @@ "name": "GenotypeToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToEntityAssociationMixin" - ], "mixin": true, "slots": [ "genotype_to_entity_association_mixin_subject", @@ -32925,6 +32632,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32935,9 +32643,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToPhenotypicFeatureAssociation", "description": "Any association between one genotype and a phenotypic feature, where having the genotype confers the phenotype, either in isolation or through environment", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "EntityToPhenotypicFeatureAssociationMixin", @@ -33000,6 +32705,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33011,9 +32717,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ExposureEventToPhenotypicFeatureAssociation", "description": "Any association between an environment and a phenotypic feature, where being in the environment influences the phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ExposureEventToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "EntityToPhenotypicFeatureAssociationMixin" @@ -33075,6 +32778,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ExposureEventToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33086,9 +32790,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseToPhenotypicFeatureAssociation", "description": "An association between a disease and a phenotypic feature in which the phenotypic feature is associated with the disease in some way.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseToPhenotypicFeatureAssociation" - ], "close_mappings": [ "dcid:DiseaseSymptomAssociation" ], @@ -33156,6 +32857,7 @@ "anatomical_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33167,9 +32869,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CaseToPhenotypicFeatureAssociation", "description": "An association between a case (e.g. individual patient) and a phenotypic feature in which the individual has or has had the phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CaseToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "EntityToPhenotypicFeatureAssociationMixin", @@ -33232,6 +32931,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CaseToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33243,9 +32943,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/BehaviorToBehavioralFeatureAssociation", "description": "An association between an mixture behavior and a behavioral feature manifested by the individual exhibited or has exhibited the behavior.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BehaviorToBehavioralFeatureAssociation" - ], "is_a": "Association", "mixins": [ "EntityToPhenotypicFeatureAssociationMixin" @@ -33307,6 +33004,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BehaviorToBehavioralFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33317,9 +33015,6 @@ "name": "GeneToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/GeneToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToEntityAssociationMixin" - ], "mixin": true, "slots": [ "gene_to_entity_association_mixin_subject", @@ -33327,6 +33022,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -33343,9 +33039,6 @@ } ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToEntityAssociationMixin" - ], "mixin": true, "slots": [ "variant_to_entity_association_mixin_subject", @@ -33353,6 +33046,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -33367,9 +33061,6 @@ "if the relationship of the statement using this predicate is statistical in nature, please use `associated with likelihood` or one of its children." ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToDiseaseOrPhenotypicFeatureAssociation" - ], "narrow_mappings": [ "WBVocab:Gene-Phenotype-Association", "dcid:DiseaseGeneAssociation", @@ -33437,6 +33128,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToDiseaseOrPhenotypicFeatureAssociation", "@type": "ClassDefinition" }, { @@ -33444,7 +33136,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToPhenotypicFeatureAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GeneToPhenotypicFeatureAssociation", "WBVocab:Gene-Phenotype-Association" ], "is_a": "GeneToDiseaseOrPhenotypicFeatureAssociation", @@ -33509,6 +33200,7 @@ "gene_to_phenotypic_feature_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33523,7 +33215,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GeneToDiseaseAssociation", "SIO:000983" ], "close_mappings": [ @@ -33591,6 +33282,7 @@ "gene_to_disease_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -33601,9 +33293,6 @@ "name": "CausalGeneToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/CausalGeneToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CausalGeneToDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "mixins": [ "EntityToDiseaseAssociationMixin", @@ -33666,6 +33355,7 @@ "causal_gene_to_disease_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CausalGeneToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -33676,9 +33366,6 @@ "name": "CorrelatedGeneToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/CorrelatedGeneToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CorrelatedGeneToDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "mixins": [ "EntityToDiseaseAssociationMixin", @@ -33741,6 +33428,7 @@ "correlated_gene_to_disease_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CorrelatedGeneToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -33751,9 +33439,6 @@ "name": "DruggableGeneToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/DruggableGeneToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DruggableGeneToDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "mixins": [ "EntityToDiseaseAssociationMixin", @@ -33816,6 +33501,7 @@ "druggable_gene_to_disease_association_has_evidence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DruggableGeneToDiseaseAssociation", "defining_slots": [ "subject", "object", @@ -33827,9 +33513,6 @@ "name": "PhenotypicFeatureToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhenotypicFeatureToDiseaseAssociation" - ], "is_a": "Association", "mixins": [ "EntityToDiseaseAssociationMixin", @@ -33889,6 +33572,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -33901,9 +33585,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/VariantToGeneAssociation", "description": "An association between a variant and a gene, where the variant has a genetic association with the gene (i.e. is in linkage disequilibrium)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToGeneAssociation" - ], "is_a": "Association", "mixins": [ "VariantToEntityAssociationMixin" @@ -33950,6 +33631,7 @@ "variant_to_gene_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToGeneAssociation", "defining_slots": [ "subject", "predicate", @@ -33962,9 +33644,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/VariantToGeneExpressionAssociation", "description": "An association between a variant and expression of a gene (i.e. e-QTL)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToGeneExpressionAssociation" - ], "is_a": "VariantToGeneAssociation", "mixins": [ "GeneExpressionMixin" @@ -34015,6 +33694,7 @@ "phenotypic_state" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToGeneExpressionAssociation", "defining_slots": [ "subject", "predicate", @@ -34027,9 +33707,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/VariantToPopulationAssociation", "description": "An association between a variant and a population, where the variant has particular frequency in the population", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToPopulationAssociation" - ], "is_a": "Association", "mixins": [ "VariantToEntityAssociationMixin", @@ -34083,6 +33760,7 @@ "frequency_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToPopulationAssociation", "defining_slots": [ "subject", "object" @@ -34094,9 +33772,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PopulationToPopulationAssociation", "description": "An association between a two populations", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PopulationToPopulationAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -34140,6 +33815,7 @@ "population_to_population_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PopulationToPopulationAssociation", "defining_slots": [ "subject", "object" @@ -34150,9 +33826,6 @@ "name": "VariantToPhenotypicFeatureAssociation", "definition_uri": "https://w3id.org/biolink/vocab/VariantToPhenotypicFeatureAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "VariantToEntityAssociationMixin", @@ -34215,6 +33888,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -34228,9 +33902,6 @@ "TODO decide no how to model pathogenicity" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToDiseaseAssociation" - ], "is_a": "Association", "mixins": [ "VariantToEntityAssociationMixin", @@ -34285,6 +33956,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -34298,9 +33970,6 @@ "TODO decide no how to model pathogenicity" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToDiseaseAssociation" - ], "is_a": "Association", "mixins": [ "GenotypeToEntityAssociationMixin", @@ -34355,6 +34024,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -34366,9 +34036,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ModelToDiseaseAssociationMixin", "description": "This mixin is used for any association class for which the subject (source node) plays the role of a 'model', in that it recapitulates some features of the disease in a way that is useful for studying the disease outside a patient carrying the disease", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ModelToDiseaseAssociationMixin" - ], "mixin": true, "slots": [ "model_to_disease_association_mixin_subject", @@ -34376,15 +34043,13 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ModelToDiseaseAssociationMixin", "@type": "ClassDefinition" }, { "name": "GeneAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/GeneAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneAsAModelOfDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34447,6 +34112,7 @@ "gene_as_a_model_of_disease_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34458,9 +34124,6 @@ "name": "VariantAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/VariantAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantAsAModelOfDiseaseAssociation" - ], "is_a": "VariantToDiseaseAssociation", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34515,6 +34178,7 @@ "variant_as_a_model_of_disease_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34526,9 +34190,6 @@ "name": "GenotypeAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/GenotypeAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeAsAModelOfDiseaseAssociation" - ], "is_a": "GenotypeToDiseaseAssociation", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34583,6 +34244,7 @@ "genotype_as_a_model_of_disease_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34594,9 +34256,6 @@ "name": "CellLineAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/CellLineAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CellLineAsAModelOfDiseaseAssociation" - ], "is_a": "CellLineToDiseaseOrPhenotypicFeatureAssociation", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34651,6 +34310,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellLineAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34662,9 +34322,6 @@ "name": "OrganismalEntityAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/OrganismalEntityAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismalEntityAsAModelOfDiseaseAssociation" - ], "is_a": "Association", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34719,6 +34376,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismalEntityAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34730,9 +34388,6 @@ "name": "OrganismToOrganismAssociation", "definition_uri": "https://w3id.org/biolink/vocab/OrganismToOrganismAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismToOrganismAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -34776,6 +34431,7 @@ "organism_to_organism_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismToOrganismAssociation", "defining_slots": [ "subject", "predicate", @@ -34787,9 +34443,6 @@ "name": "TaxonToTaxonAssociation", "definition_uri": "https://w3id.org/biolink/vocab/TaxonToTaxonAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:TaxonToTaxonAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -34833,6 +34486,7 @@ "taxon_to_taxon_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TaxonToTaxonAssociation", "defining_slots": [ "subject", "predicate", @@ -34844,9 +34498,6 @@ "name": "GeneHasVariantThatContributesToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/GeneHasVariantThatContributesToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneHasVariantThatContributesToDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "slots": [ "id", @@ -34906,6 +34557,7 @@ "gene_has_variant_that_contributes_to_disease_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneHasVariantThatContributesToDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34924,9 +34576,6 @@ "see_also": [ "https://github.com/monarch-initiative/ingest-artifacts/tree/master/sources/BGee" ], - "exact_mappings": [ - "biolink:GeneToExpressionSiteAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -34972,6 +34621,7 @@ "gene_to_expression_site_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToExpressionSiteAssociation", "defining_slots": [ "subject", "predicate", @@ -34987,9 +34637,6 @@ "An alternate way to model the same information could be via a qualifier" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SequenceVariantModulatesTreatmentAssociation" - ], "is_a": "Association", "abstract": true, "slots": [ @@ -35034,6 +34681,7 @@ "sequence_variant_modulates_treatment_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SequenceVariantModulatesTreatmentAssociation", "defining_slots": [ "subject", "object" @@ -35045,9 +34693,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/FunctionalAssociation", "description": "An association between a macromolecular machine mixin (gene, gene product or complex of gene products) and either a molecular activity, a biological process or a cellular location in which a function is executed.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FunctionalAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35091,6 +34736,7 @@ "functional_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FunctionalAssociation", "@type": "ClassDefinition" }, { @@ -35098,9 +34744,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToEntityAssociationMixin", "description": "an association which has a macromolecular machine mixin as a subject", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineToEntityAssociationMixin" - ], "mixin": true, "slots": [ "macromolecular_machine_to_entity_association_mixin_subject", @@ -35109,6 +34752,7 @@ "species_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToEntityAssociationMixin", "@type": "ClassDefinition" }, { @@ -35116,9 +34760,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToMolecularActivityAssociation", "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a molecular activity (as represented in the GO molecular function branch), where the entity carries out the activity, or contributes to its execution.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineToMolecularActivityAssociation" - ], "is_a": "FunctionalAssociation", "mixins": [ "MacromolecularMachineToEntityAssociationMixin" @@ -35166,6 +34807,7 @@ "species_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToMolecularActivityAssociation", "@type": "ClassDefinition" }, { @@ -35173,9 +34815,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToBiologicalProcessAssociation", "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a biological process or pathway (as represented in the GO biological process branch), where the entity carries out some part of the process, regulates it, or acts upstream of it.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineToBiologicalProcessAssociation" - ], "is_a": "FunctionalAssociation", "mixins": [ "MacromolecularMachineToEntityAssociationMixin" @@ -35223,6 +34862,7 @@ "species_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToBiologicalProcessAssociation", "@type": "ClassDefinition" }, { @@ -35230,9 +34870,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToCellularComponentAssociation", "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a cellular component (as represented in the GO cellular component branch), where the entity carries out its function in the cellular component.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineToCellularComponentAssociation" - ], "is_a": "FunctionalAssociation", "mixins": [ "MacromolecularMachineToEntityAssociationMixin" @@ -35280,6 +34917,7 @@ "species_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToCellularComponentAssociation", "@type": "ClassDefinition" }, { @@ -35287,9 +34925,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MolecularActivityToChemicalEntityAssociation", "description": "Added in response to capturing relationship between microbiome activities as measured via measurements of blood analytes as collected via blood and stool samples", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularActivityToChemicalEntityAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35333,6 +34968,7 @@ "molecular_activity_to_chemical_entity_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularActivityToChemicalEntityAssociation", "@type": "ClassDefinition" }, { @@ -35340,9 +34976,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MolecularActivityToMolecularActivityAssociation", "description": "Added in response to capturing relationship between microbiome activities as measured via measurements of blood analytes as collected via blood and stool samples", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularActivityToMolecularActivityAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35386,6 +35019,7 @@ "molecular_activity_to_molecular_activity_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularActivityToMolecularActivityAssociation", "@type": "ClassDefinition" }, { @@ -35396,7 +35030,6 @@ "functional association" ], "exact_mappings": [ - "biolink:GeneToGoTermAssociation", "WBVocab:Gene-GO-Association" ], "is_a": "FunctionalAssociation", @@ -35442,6 +35075,7 @@ "gene_to_go_term_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGoTermAssociation", "defining_slots": [ "subject", "object" @@ -35452,9 +35086,6 @@ "name": "EntityToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToDiseaseAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35500,6 +35131,7 @@ "max_research_phase" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -35510,9 +35142,6 @@ "name": "EntityToPhenotypicFeatureAssociation", "definition_uri": "https://w3id.org/biolink/vocab/EntityToPhenotypicFeatureAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToPhenotypicFeatureAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35558,6 +35187,7 @@ "max_research_phase" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -35569,9 +35199,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/SequenceAssociation", "description": "An association between a sequence feature and a nucleic acid entity it is localized to.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SequenceAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35615,6 +35242,7 @@ "association_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SequenceAssociation", "@type": "ClassDefinition" }, { @@ -35623,7 +35251,6 @@ "description": "A relationship between a sequence feature and a nucleic acid entity it is localized to. The reference entity may be a chromosome, chromosome region or information entity such as a contig.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GenomicSequenceLocalization", "dcid:GenomeAnnotation" ], "broad_mappings": [ @@ -35677,6 +35304,7 @@ "genomic_sequence_localization_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenomicSequenceLocalization", "@type": "ClassDefinition" }, { @@ -35685,7 +35313,6 @@ "description": "For example, a particular exon is part of a particular transcript or gene", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:SequenceFeatureRelationship", "CHADO:feature_relationship" ], "is_a": "Association", @@ -35731,6 +35358,7 @@ "sequence_feature_relationship_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SequenceFeatureRelationship", "defining_slots": [ "subject", "object" @@ -35742,9 +35370,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/TranscriptToGeneRelationship", "description": "A gene is a collection of transcripts", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:TranscriptToGeneRelationship" - ], "is_a": "SequenceFeatureRelationship", "slots": [ "id", @@ -35788,6 +35413,7 @@ "transcript_to_gene_relationship_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TranscriptToGeneRelationship", "defining_slots": [ "subject", "object" @@ -35799,9 +35425,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToGeneProductRelationship", "description": "A gene is transcribed and potentially translated to a gene product", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToGeneProductRelationship" - ], "is_a": "SequenceFeatureRelationship", "slots": [ "id", @@ -35845,6 +35468,7 @@ "gene_to_gene_product_relationship_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneProductRelationship", "defining_slots": [ "subject", "object" @@ -35856,9 +35480,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ExonToTranscriptRelationship", "description": "A transcript is formed from multiple exons", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ExonToTranscriptRelationship" - ], "is_a": "SequenceFeatureRelationship", "slots": [ "id", @@ -35902,6 +35523,7 @@ "exon_to_transcript_relationship_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ExonToTranscriptRelationship", "defining_slots": [ "subject", "object" @@ -35913,9 +35535,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation", "description": "A regulatory relationship between two genes", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35960,15 +35579,13 @@ "chemical_entity_or_gene_or_gene_product_regulates_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation", "@type": "ClassDefinition" }, { "name": "AnatomicalEntityToAnatomicalEntityAssociation", "definition_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:AnatomicalEntityToAnatomicalEntityAssociation" - ], "is_a": "Association", "abstract": true, "slots": [ @@ -36013,6 +35630,7 @@ "anatomical_entity_to_anatomical_entity_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityAssociation", "defining_slots": [ "subject", "object" @@ -36024,9 +35642,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityPartOfAssociation", "description": "A relationship between two anatomical entities where the relationship is mereological, i.e the two entities are related by parthood. This includes relationships between cellular components and cells, between cells and tissues, tissues and whole organisms", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:AnatomicalEntityToAnatomicalEntityPartOfAssociation" - ], "is_a": "AnatomicalEntityToAnatomicalEntityAssociation", "slots": [ "id", @@ -36070,6 +35685,7 @@ "anatomical_entity_to_anatomical_entity_part_of_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityPartOfAssociation", "defining_slots": [ "predicate" ], @@ -36080,9 +35696,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityOntogenicAssociation", "description": "A relationship between two anatomical entities where the relationship is ontogenic, i.e. the two entities are related by development. A number of different relationship types can be used to specify the precise nature of the relationship.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:AnatomicalEntityToAnatomicalEntityOntogenicAssociation" - ], "is_a": "AnatomicalEntityToAnatomicalEntityAssociation", "slots": [ "id", @@ -36126,6 +35739,7 @@ "anatomical_entity_to_anatomical_entity_ontogenic_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityOntogenicAssociation", "defining_slots": [ "predicate" ], @@ -36136,9 +35750,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToEntityAssociation", "description": "An association between an organism taxon and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToEntityAssociation" - ], "mixin": true, "slots": [ "organism_taxon_to_entity_association_subject", @@ -36146,6 +35757,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToEntityAssociation", "defining_slots": [ "subject" ], @@ -36156,9 +35768,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonAssociation", "description": "A relationship between two organism taxon nodes", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToOrganismTaxonAssociation" - ], "is_a": "Association", "abstract": true, "mixins": [ @@ -36206,6 +35815,7 @@ "organism_taxon_to_organism_taxon_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonAssociation", "defining_slots": [ "subject", "object" @@ -36217,9 +35827,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonSpecialization", "description": "A child-parent relationship between two taxa. For example: Homo sapiens subclass_of Homo", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToOrganismTaxonSpecialization" - ], "is_a": "OrganismTaxonToOrganismTaxonAssociation", "slots": [ "id", @@ -36263,6 +35870,7 @@ "organism_taxon_to_organism_taxon_specialization_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonSpecialization", "defining_slots": [ "predicate" ], @@ -36273,9 +35881,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonInteraction", "description": "An interaction relationship between two taxa. This may be a symbiotic relationship (encompassing mutualism and parasitism), or it may be non-symbiotic. Example: plague transmitted_by flea; cattle domesticated_by Homo sapiens; plague infects Homo sapiens", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToOrganismTaxonInteraction" - ], "is_a": "OrganismTaxonToOrganismTaxonAssociation", "slots": [ "id", @@ -36320,6 +35925,7 @@ "organism_taxon_to_organism_taxon_interaction_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonInteraction", "defining_slots": [ "predicate" ], @@ -36329,9 +35935,6 @@ "name": "OrganismTaxonToEnvironmentAssociation", "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToEnvironmentAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToEnvironmentAssociation" - ], "is_a": "Association", "abstract": true, "mixins": [ @@ -36379,6 +35982,7 @@ "organism_taxon_to_environment_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToEnvironmentAssociation", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_compliance/test_boolean_slot_compliance.py b/tests/linkml/test_compliance/test_boolean_slot_compliance.py index fd900bf938..1b1f82d592 100644 --- a/tests/linkml/test_compliance/test_boolean_slot_compliance.py +++ b/tests/linkml/test_compliance/test_boolean_slot_compliance.py @@ -505,7 +505,7 @@ def test_class_any_of(framework, data_name, s1value, s2value, is_valid): core_elements=["any_of", "ClassDefinition"], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [OWL]: + if framework not in [OWL, SHACL]: # TODO: rdflib transformer has issues around ranges expected_behavior = ValidationBehavior.INCOMPLETE # TODO: rdflib transformer has issues around ranges @@ -632,8 +632,22 @@ def test_class_any_of_with_required(framework, nest, op, name, family_name, give core_elements=[op, "ClassDefinition"], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [JSON_SCHEMA]: + if framework not in [JSON_SCHEMA, SHACL]: expected_behavior = ValidationBehavior.INCOMPLETE + elif framework == SHACL and 5 in (name, family_name, given_name): + # SHACL validation makes its instances through python dataclasses, which coerce + # the integer to a string, so the range violation never reaches the shapes. A + # row can then only be detected through the operator itself. + present = [value is not None for value in (name, family_name, given_name)] + members = [present[0], present[1] and present[2]] + operator_holds = { + "any_of": any(members), + "all_of": all(members), + "exactly_one_of": sum(members) == 1, + "none_of": not any(members), + }[op] + if operator_holds: + expected_behavior = ValidationBehavior.INCOMPLETE data = {SLOT_S1: name, SLOT_S2: family_name, SLOT_S3: given_name} if nest: @@ -2809,6 +2823,9 @@ def test_membership(framework, name, quantification, expression, instance, is_va expected_behavior = ValidationBehavior.INCOMPLETE if framework in [SHACL, SQL_DDL_SQLITE, PANDERA_POLARS_CLASS]: expected_behavior = ValidationBehavior.INCOMPLETE + if framework == SHACL and quantification == "has_member" and s1_range != CLASS_D: + # sh:qualifiedValueShape; equals_string on a reference is not translated + expected_behavior = ValidationBehavior.IMPLEMENTS if framework == OWL and name == "all_obj_members_equals_string" and not is_valid: # This test case relies on punning, as s1 is used as both an OP and DP, # so we do not expect a DL-reasoner to be able to handle it @@ -2889,7 +2906,7 @@ def test_range_expression_nesting(framework, data_name, instance, is_valid): core_elements=["range_expression"], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [JSON_SCHEMA, OWL]: + if framework not in [JSON_SCHEMA, OWL, SHACL]: if not is_valid: expected_behavior = ValidationBehavior.INCOMPLETE check_data( diff --git a/tests/linkml/test_enhancements/__snapshots__/enumeration/alternatives.yaml b/tests/linkml/test_enhancements/__snapshots__/enumeration/alternatives.yaml index cb8ebe66b2..2397ef8259 100644 --- a/tests/linkml/test_enhancements/__snapshots__/enumeration/alternatives.yaml +++ b/tests/linkml/test_enhancements/__snapshots__/enumeration/alternatives.yaml @@ -480,8 +480,6 @@ classes: definition_uri: http://example.org/test/evidence/AllEnums description: A class that incorporates all of the enumeration examples above from_schema: http://example.org/test/alternatives - exact_mappings: - - evidence:AllEnums slots: - allEnums__entry_name - allEnums__code_1 diff --git a/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml b/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml index d9a4667ace..78b7c26703 100644 --- a/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml +++ b/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml @@ -24,25 +24,12 @@ schema: minimum_value: 2 maximum_value: 5 - int_list_with_has_member: - range: integer - multivalued: true - has_member: - minimum_value: 2 - maximum_value: 5 - string_list_with_all_members: range: string multivalued: true all_members: pattern: e.* - string_list_with_has_member: - range: string - multivalued: true - has_member: - pattern: e.* - classes: Test: tree_root: true @@ -50,9 +37,7 @@ schema: - int_list - string_list - int_list_with_all_members - - int_list_with_has_member - string_list_with_all_members - - string_list_with_has_member json_schema: properties: int_list: @@ -69,19 +54,10 @@ json_schema: minimum: 2 maximum: 5 type: array - int_list_with_has_member: - contains: - minimum: 2 - maximum: 5 - type: array string_list_with_all_members: items: pattern: e.* type: array - string_list_with_has_member: - contains: - pattern: e.* - type: array data_cases: - data: int_list: [2, 3, 4, 5] @@ -105,13 +81,6 @@ data_cases: - data: int_list_with_all_members: [1, 2, 3] error_message: Failed validating 'minimum' - - data: - int_list_with_has_member: [2, 3, 4, 5] - - data: - int_list_with_has_member: [0, 1, 2] - - data: - int_list_with_has_member: [6, 7, 8] - error_message: Failed validating 'contains' - data: string_list_with_all_members: - echo @@ -121,16 +90,3 @@ data_cases: - echo - foxtrot error_message: Failed validating 'pattern' - - data: - string_list_with_has_member: - - echo - - elephant - - data: - string_list_with_has_member: - - echo - - foxtrot - - data: - string_list_with_has_member: - - foxtrot - - golf - error_message: Failed validating 'contains' diff --git a/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml b/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml new file mode 100644 index 0000000000..ce52cf7a38 --- /dev/null +++ b/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml @@ -0,0 +1,80 @@ +schema: + id: http://example.org/jsonschema_multivalued_has_member + name: jsonschema_multivalued_has_member + + imports: + - https://w3id.org/linkml/types + + slots: + int_list_with_has_member: + range: integer + multivalued: true + has_member: + minimum_value: 2 + maximum_value: 5 + + string_list_with_has_member: + range: string + multivalued: true + has_member: + pattern: e.* + + classes: + Test: + tree_root: true + slots: + - int_list_with_has_member + - string_list_with_has_member +json_schema: + properties: + int_list_with_has_member: + contains: + minimum: 2 + maximum: 5 + type: array + string_list_with_has_member: + contains: + pattern: e.* + type: array + required: + - int_list_with_has_member + - string_list_with_has_member +data_cases: + - data: + int_list_with_has_member: [2, 3, 4, 5] + string_list_with_has_member: [echo] + - data: + int_list_with_has_member: [0, 1, 2] + string_list_with_has_member: [echo] + - data: + int_list_with_has_member: [6, 7, 8] + string_list_with_has_member: [echo] + error_message: Failed validating 'contains' + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - echo + - elephant + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - echo + - foxtrot + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - foxtrot + - golf + error_message: Failed validating 'contains' + # a member is a value, so a list without a value has no member + - data: + int_list_with_has_member: [] + string_list_with_has_member: [echo] + error_message: Failed validating 'contains' + - data: + int_list_with_has_member: null + string_list_with_has_member: [echo] + error_message: Failed validating 'type' + - data: + string_list_with_has_member: [echo] + error_message: Failed validating 'required' diff --git a/tests/linkml/test_generators/input/shaclgen/any_of_pattern.yaml b/tests/linkml/test_generators/input/shaclgen/any_of_pattern.yaml new file mode 100644 index 0000000000..5b247bb2a1 --- /dev/null +++ b/tests/linkml/test_generators/input/shaclgen/any_of_pattern.yaml @@ -0,0 +1,59 @@ +id: https://w3id.org/linkml/examples/any_of_pattern +name: test_any_of_pattern +description: >- + Test schema for pattern constraints inside any_of branches. + Exercises three cases: (1) pattern-only branch (no range), + (2) range + pattern on the same branch, (3) mixed branches + where some have pattern and some do not. +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://w3id.org/linkml/examples/any_of_pattern/ +imports: + - linkml:types +default_range: string +default_prefix: ex + +enums: + LicenseEnum: + permissible_values: + MIT: + Apache-2.0: + GPL-3.0-only: + +classes: + PatternOnlyBranch: + description: >- + A class where one any_of branch specifies only a pattern + (no range). The generated SHACL sh:or should contain a + node with sh:pattern but no sh:datatype or sh:class. + attributes: + license: + any_of: + - range: LicenseEnum + - range: uri + - pattern: "^LicenseRef-[a-zA-Z0-9\\-\\.]+$" + + RangeWithPattern: + description: >- + A class where an any_of branch combines range + pattern. + The generated SHACL sh:or node should have both sh:datatype + and sh:pattern. + attributes: + identifier: + any_of: + - range: string + pattern: "^[A-Z]{2}-[0-9]{4}$" + - range: integer + + MixedBranches: + description: >- + A class with three any_of branches: one with range only, + one with pattern only, one with range + pattern. Ensures + pattern is emitted only on branches that declare it. + attributes: + code: + any_of: + - range: integer + - pattern: "^CUSTOM-.*$" + - range: string + pattern: "^STD-[0-9]+$" diff --git a/tests/linkml/test_generators/test_anyuri_literal_types.py b/tests/linkml/test_generators/test_anyuri_literal_types.py new file mode 100644 index 0000000000..06a83b898f --- /dev/null +++ b/tests/linkml/test_generators/test_anyuri_literal_types.py @@ -0,0 +1,253 @@ +"""``xsd:anyURI``: an IRI for ``uri`` and ``uriorcurie``, a typed literal for types derived from ``string``. + +A schema can need both behaviours. A slot that points to a resource is an IRI node; a slot +that holds a URI reference as data - a relative file path such as ``./data/file.png`` - must +stay a literal, because JSON-LD resolves an ``@id`` against the document base and turns the +path into a machine-specific absolute IRI. The second is a type derived from ``string`` that +declares ``uri: xsd:anyURI``. + +The OWL generator already decides by type ancestry (:func:`is_xsd_anyuri_range`). These tests +pin the JSON-LD context generator, the SHACL generator and the RDF dumper to the same rule. +""" + +import json + +import pytest +import yaml +from pyshacl import validate +from pyshex import ShExEvaluator +from rdflib import OWL, RDF, SH, XSD, BNode, Graph, Literal, URIRef +from rdflib.compare import isomorphic + +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_runtime.dumpers import rdflib_dumper +from linkml_runtime.utils.compile_python import compile_python +from linkml_runtime.utils.schemaview import SchemaView + +SCHEMA = """ +id: https://example.org/anyuri +name: anyuri +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +types: + FilePath: + typeof: string + uri: xsd:anyURI + description: A URI reference kept as a literal; it may be relative. + Homepage: + typeof: uri + description: A uri by derivation, so an IRI. + +slots: + id: + identifier: true + range: uriorcurie + link: + range: uri + slot_uri: ex:link + homepage: + range: Homepage + slot_uri: ex:homepage + path: + range: FilePath + slot_uri: ex:path + +classes: + Resource: + slots: [id, link, homepage, path] +""" + +EX = "https://example.org/" + + +@pytest.mark.parametrize("xsd_anyuri_as_iri", [False, True]) +def test_context_keeps_string_derived_anyuri_a_literal(xsd_anyuri_as_iri: bool) -> None: + """The existing option promotes only the URI family, not literal-valued types.""" + ctx = json.loads(ContextGenerator(SCHEMA, xsd_anyuri_as_iri=xsd_anyuri_as_iri).serialize())["@context"] + assert ctx["path"]["@type"] == "xsd:anyURI" + expected_iri = "@id" if xsd_anyuri_as_iri else "xsd:anyURI" + assert ctx["link"]["@type"] == expected_iri + assert ctx["homepage"]["@type"] == expected_iri + + +ANY_OF_SCHEMA = """ +id: https://example.org/anyuri-any-of +name: anyuri_any_of +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_prefix: ex +default_range: string +types: + FilePath: + typeof: string + uri: xsd:anyURI +slots: + either_path: + slot_uri: ex:eitherPath + any_of: + - range: Target + - range: FilePath + either_uri: + slot_uri: ex:eitherUri + any_of: + - range: Target + - range: uri +classes: + Target: + class_uri: ex:Target + Holder: + slots: [either_path, either_uri] +""" + + +def test_context_any_of_with_string_derived_anyuri_is_not_promoted() -> None: + """A mixed union coerces scalar values to literals; class values can use explicit node objects.""" + ctx = json.loads(ContextGenerator(ANY_OF_SCHEMA, xsd_anyuri_as_iri=True).serialize())["@context"] + assert ctx["either_path"]["@type"] == "xsd:anyURI" + # a uri branch agrees with the class branch, as before + assert ctx["either_uri"]["@type"] == "@id" + + +def _property_shape(g: Graph, path: str) -> URIRef | BNode: + """Find the generated property shape for an example property.""" + return next(ps for ps in g.subjects(SH.path, URIRef(EX + path))) + + +def test_shacl_string_derived_anyuri_is_a_typed_literal() -> None: + """SHACL preserves literal types while retaining URI-family node kinds.""" + g = Graph().parse(data=ShaclGenerator(SCHEMA, mergeimports=True).serialize(), format="turtle") + + path = _property_shape(g, "path") + assert g.value(path, SH.nodeKind) == SH.Literal + assert g.value(path, SH.datatype) == XSD.anyURI + + for iri_slot in ("link", "homepage"): + ps = _property_shape(g, iri_slot) + assert g.value(ps, SH.nodeKind) == SH.IRI, iri_slot + assert g.value(ps, SH.datatype) is None, iri_slot + + +@pytest.mark.parametrize("xsd_anyuri_as_iri", [False, True]) +def test_owl_string_derived_anyuri_is_a_datatype_property(xsd_anyuri_as_iri: bool) -> None: + """OWL and JSON-LD use the same option boundary.""" + g = OwlSchemaGenerator(SCHEMA, xsd_anyuri_as_iri=xsd_anyuri_as_iri, type_objects=False).as_graph() + assert (URIRef(EX + "path"), RDF.type, OWL.DatatypeProperty) in g + expected = OWL.ObjectProperty if xsd_anyuri_as_iri else OWL.DatatypeProperty + assert (URIRef(EX + "link"), RDF.type, expected) in g + + +def test_dumper_string_derived_anyuri_is_a_typed_literal() -> None: + """Generated Python instances preserve relative URI literals in RDF.""" + module = compile_python(str(PythonGenerator(SCHEMA, mergeimports=True).serialize())) + resource = module.Resource( + id="ex:r1", + link="https://example.org/target", + homepage="https://example.org/home", + path="./data/file.png", + ) + g = rdflib_dumper.as_rdf_graph(resource, schemaview=SchemaView(SCHEMA)) + subject = URIRef(EX + "r1") + + assert g.value(subject, URIRef(EX + "path")) == Literal("./data/file.png", datatype=XSD.anyURI) + assert g.value(subject, URIRef(EX + "link")) == URIRef("https://example.org/target") + assert g.value(subject, URIRef(EX + "homepage")) == URIRef("https://example.org/home") + + +@pytest.mark.parametrize("datatype", ["xsd:anyURI", "xs:anyURI", str(XSD.anyURI)]) +@pytest.mark.parametrize("derived", [False, True]) +@pytest.mark.parametrize("path", ["./data/file.png", "#section", "https://example.net/file"]) +def test_rdf_representation_contract(datatype: str, derived: bool, path: str) -> None: + """Generated contexts and Python RDF agree, and both shape languages validate them.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["xs"] = str(XSD) + schema["types"]["FilePath"]["uri"] = datatype + schema["types"]["Homepage"]["uri"] = datatype + if derived: + for name in ("FilePath", "Homepage"): + schema["types"][name + "Child"] = {"typeof": name} + schema["slots"]["path"]["range"] = "FilePathChild" + schema["slots"]["homepage"]["range"] = "HomepageChild" + source = yaml.safe_dump(schema) + context = json.loads(ContextGenerator(source, xsd_anyuri_as_iri=True).serialize())["@context"] + document = { + "@context": context, + "@type": "Resource", + "id": "ex:r1", + "link": "https://example.org/target", + "homepage": "https://example.org/home", + "path": path, + } + graph = Graph().parse(data=json.dumps(document), format="json-ld", publicID="https://unrelated.example/base/") + subject = URIRef(EX + "r1") + predicate = URIRef(EX + "path") + assert graph.value(subject, predicate) == Literal(path, datatype=XSD.anyURI) + module = compile_python(str(PythonGenerator(source).serialize())) + instance = module.Resource(**{key: value for key, value in document.items() if not key.startswith("@")}) + dumped = rdflib_dumper.as_rdf_graph(instance, schemaview=SchemaView(source)) + assert isomorphic(graph, dumped) + + shacl = ShaclGenerator(source).as_graph() + shex = ShExGenerator(source).serialize() + assert validate(graph, shacl_graph=shacl, meta_shacl=True)[0] + results = ShExEvaluator(rdf=graph, schema=shex, focus=subject, start=EX + "Resource").evaluate() + assert results and all(result.result for result in results), [result.reason for result in results] + owl = OwlSchemaGenerator(source, xsd_anyuri_as_iri=True, type_objects=False).as_graph() + assert (predicate, RDF.type, OWL.DatatypeProperty) in owl + assert (URIRef(EX + "homepage"), RDF.type, OWL.ObjectProperty) in owl + + for wrong in (URIRef("https://example.org/file"), Literal(path)): + graph.set((subject, predicate, wrong)) + assert not validate(graph, shacl_graph=shacl)[0] + results = ShExEvaluator(rdf=graph, schema=shex, focus=subject, start=EX + "Resource").evaluate() + assert results and not any(result.result for result in results) + + +def test_context_uses_supplied_schema_without_reopening_source() -> None: + """A source_file annotation is provenance, not permission to replace the given schema.""" + schema = SchemaView(SCHEMA).schema + schema.source_file = "/not/a/real/schema.yaml" + context = json.loads(ContextGenerator(schema, xsd_anyuri_as_iri=True).serialize())["@context"] + assert context["path"]["@type"] == "xsd:anyURI" + assert context["homepage"]["@type"] == "@id" + + +def test_uri_ancestry_with_string_datatype_is_not_promoted() -> None: + """An explicit datatype override must agree across all generators.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Homepage"]["uri"] = "xsd:string" + source = yaml.safe_dump(schema) + context = json.loads(ContextGenerator(source, xsd_anyuri_as_iri=True).serialize())["@context"] + assert context["homepage"].get("@type") != "@id" + owl = OwlSchemaGenerator(source, xsd_anyuri_as_iri=True, type_objects=False).as_graph() + assert (URIRef(EX + "homepage"), RDF.type, OWL.DatatypeProperty) in owl + shacl = ShaclGenerator(source).as_graph() + assert shacl.value(_property_shape(shacl, "homepage"), SH.datatype) == XSD.string + dumper = rdflib_dumper.inject_triples("https://example.org/value", SchemaView(source), Graph(), "Homepage") + assert dumper == Literal("https://example.org/value") + + +@pytest.mark.parametrize("as_node", [False, True]) +def test_jsonld_union_preserves_literal_and_explicit_node_values(as_node: bool) -> None: + """Both alternatives retain their RDF terms through JSON-LD expansion and SHACL.""" + context = json.loads(ContextGenerator(ANY_OF_SCHEMA, xsd_anyuri_as_iri=True).serialize())["@context"] + value = {"@id": EX + "target", "@type": "Target"} if as_node else "./data/file.png" + document = {"@context": context, "@id": EX + "holder", "@type": "Holder", "either_path": value} + graph = Graph().parse(data=json.dumps(document), format="json-ld", publicID="https://unrelated.example/base/") + expected = URIRef(EX + "target") if as_node else Literal(value, datatype=XSD.anyURI) + assert graph.value(URIRef(EX + "holder"), URIRef(EX + "eitherPath")) == expected + assert validate(graph, shacl_graph=ShaclGenerator(ANY_OF_SCHEMA).as_graph(), meta_shacl=True)[0] + graph.set((URIRef(EX + "holder"), URIRef(EX + "eitherPath"), Literal("wrong datatype"))) + assert not validate(graph, shacl_graph=ShaclGenerator(ANY_OF_SCHEMA).as_graph())[0] diff --git a/tests/linkml/test_generators/test_declared_annotations.py b/tests/linkml/test_generators/test_declared_annotations.py new file mode 100644 index 0000000000..1222e11905 --- /dev/null +++ b/tests/linkml/test_generators/test_declared_annotations.py @@ -0,0 +1,271 @@ +"""Declared annotation ranges determine RDF terms independently of vocabulary.""" + +from pathlib import Path + +import pytest +import yaml +from pyshacl import validate +from rdflib import RDF, SH, XSD, BNode, Graph, Literal, URIRef + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator + +EX = "https://example.org/" +SCHEMA = """ +id: https://example.org/model +name: model +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: [linkml:types] +default_prefix: ex +types: + Reference: + typeof: nodeidentifier + Text: + typeof: string +classes: + Metadata: + class_uri: ex:Metadata + attributes: + reference: + slot_uri: ex:reference + range: Reference + text: + slot_uri: ex:text + range: Text + Profile: + is_a: Metadata + class_uri: ex:Profile + Thing: + instantiates: [ex:Profile] + annotations: + ex:reference: ex:target + ex:text: https://example.org/literal-text +""" + + +def _graph(source: str | Path, generator: str, **kwargs: object) -> Graph: + """Exercise generated Turtle, including term preservation after parsing.""" + if generator == "owl": + output = OwlSchemaGenerator(source, **kwargs).serialize() + else: + output = ShaclGenerator(source, include_annotations=True, **kwargs).serialize() + return Graph().parse(data=output, format="turtle") + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("tag_form", ["local", "curie", "iri"]) +@pytest.mark.parametrize("value", ["ex:target", "https://example.net/target", "urn:example:target"]) +def test_declared_ranges(generator: str, tag_form: str, value: str) -> None: + """Inherited declarations handle arbitrary predicates, CURIEs and absolute IRIs.""" + schema = yaml.safe_load(SCHEMA) + prefix = {"local": "", "curie": "ex:", "iri": EX}[tag_form] + schema["classes"]["Thing"]["annotations"] = { + prefix + "reference": value, + prefix + "text": EX + "literal-text", + } + graph = _graph(yaml.safe_dump(schema), generator) + expected = URIRef(EX + "target" if value == "ex:target" else value) + assert list(graph.objects(None, URIRef(EX + "reference"))) == [expected] + assert list(graph.objects(None, URIRef(EX + "text"))) == [Literal(EX + "literal-text")] + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("owner", ["class", "slot", "type"]) +def test_metadata_owners(generator: str, owner: str) -> None: + """Every annotated shape/resource uses the same declared term conversion.""" + schema = yaml.safe_load(SCHEMA) + metadata = {"instantiates": ["ex:Profile"], "annotations": {"ex:reference": "ex:target"}} + schema["classes"]["Thing"].pop("annotations") + if owner == "class": + schema["classes"]["Thing"].update(metadata) + elif owner == "slot": + schema["classes"]["Thing"]["attributes"] = {"value": {"range": "string", **metadata}} + else: + schema["types"]["Text"].update(metadata) + schema["classes"]["Thing"]["attributes"] = {"value": {"range": "Text"}} + graph = _graph(yaml.safe_dump(schema), generator) + assert URIRef(EX + "target") in graph.objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_imported_metaclass(tmp_path: Path, generator: str) -> None: + """Imported definitions are resolved by class URI, including inherited slots.""" + profile = yaml.safe_load(SCHEMA) + del profile["classes"]["Thing"] + (tmp_path / "profile.yaml").write_text(yaml.safe_dump(profile)) + schema = yaml.safe_load(SCHEMA) + schema["id"] = EX + "consumer" + schema["imports"] = ["profile"] + del schema["types"] + schema["classes"] = {"Thing": schema["classes"]["Thing"]} + source = tmp_path / "consumer.yaml" + source.write_text(yaml.safe_dump(schema)) + assert URIRef(EX + "target") in _graph(source, generator).objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("value", ["plain text", "relative/path", "https://example.org/%ZZ", 42]) +def test_invalid_node_identifier_fails(generator: str, value: object) -> None: + """A declared node cannot silently become a literal or an invalid RDF IRI.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"]["ex:reference"] = value + with pytest.raises(ValueError, match="requires (an absolute IRI|a node identifier)"): + _graph(yaml.safe_dump(schema), generator) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_blank_node_and_language(generator: str) -> None: + """Explicit node identifiers allow blank nodes; only text receives a language tag.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"]["ex:reference"] = "_:target" + graph = _graph(yaml.safe_dump(schema), generator, default_language="en") + assert isinstance(next(graph.objects(None, URIRef(EX + "reference"))), BNode) + assert Literal(EX + "literal-text", lang="en") in graph.objects(None, URIRef(EX + "text")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_conflicting_metaclasses_fail(generator: str) -> None: + """Different RDF terms cannot be selected by the order of metaclass declarations.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Other"] = {"attributes": {"reference": {"slot_uri": "ex:reference", "range": "string"}}} + schema["classes"]["Thing"]["instantiates"].append("ex:Other") + with pytest.raises(ValueError, match="Conflicting metaclass"): + _graph(yaml.safe_dump(schema), generator) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_uri_literal_is_distinct_from_node(generator: str) -> None: + """The xsd:anyURI datatype does not by itself require an IRI node.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Text"]["uri"] = "xsd:anyURI" + graph = _graph(yaml.safe_dump(schema), generator) + assert Literal(EX + "literal-text", datatype=XSD.anyURI) in graph.objects(None, URIRef(EX + "text")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_curie_range_requires_expansion(generator: str) -> None: + """The standard curie type requires expansion in RDF despite its string datatype.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Reference"]["typeof"] = "curie" + graph = _graph(yaml.safe_dump(schema), generator) + assert URIRef(EX + "target") in graph.objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("unsupported", ["structured", "class", "union"]) +def test_unsupported_declared_representation_fails(generator: str, unsupported: str) -> None: + """Unsupported values and mixed range expressions are not silently stringified.""" + schema = yaml.safe_load(SCHEMA) + slot = schema["classes"]["Metadata"]["attributes"]["reference"] + if unsupported == "structured": + schema["classes"]["Thing"]["annotations"]["ex:reference"] = {"value": {"nested": "value"}} + elif unsupported == "class": + slot["range"] = "Thing" + else: + slot["any_of"] = [{"range": "nodeidentifier"}, {"range": "string"}] + with pytest.raises(ValueError, match="scalar"): + _graph(yaml.safe_dump(schema), generator) + + +def test_untyped_metadata_preserves_literal_values() -> None: + """OWL does not infer RDF node kinds from familiar vocabulary names or URL text.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["license"] = "https://example.org/license" + schema["annotations"] = {"dcterms:license": "SPDX:MIT"} + graph = _graph(yaml.safe_dump(schema), "owl") + predicate = URIRef("http://purl.org/dc/terms/license") + assert set(graph.objects(URIRef(EX + "model"), predicate)) == { + Literal("https://example.org/license"), + Literal("SPDX:MIT"), + } + + +def test_owl_header_enum_and_permissible_value() -> None: + """OWL annotation declarations also apply to the ontology and vocabulary resources.""" + schema = yaml.safe_load(SCHEMA) + metadata = {"instantiates": ["ex:Profile"], "annotations": {"ex:reference": "ex:target"}} + schema.update(metadata) + schema["enums"] = {"Choice": {**metadata, "permissible_values": {"A": {"meaning": "ex:A", **metadata}}}} + graph = _graph(yaml.safe_dump(schema), "owl") + for subject in [EX + "model", EX + "Choice", EX + "A"]: + assert (URIRef(subject), URIRef(EX + "reference"), URIRef(EX + "target")) in graph + + +def test_annotations_do_not_constrain_data() -> None: + """Metadata appears on a shape without becoming a required data property.""" + graph = _graph(SCHEMA, "shacl") + shape = next(graph.subjects(SH.targetClass, URIRef(EX + "Thing"))) + assert (shape, URIRef(EX + "reference"), URIRef(EX + "target")) in graph + for prop in graph.objects(shape, SH.property): + assert (prop, SH.path, URIRef(EX + "reference")) not in graph + assert (shape, RDF.type, SH.NodeShape) in graph + data = Graph() + data.add((URIRef(EX + "instance"), RDF.type, URIRef(EX + "Thing"))) + assert validate(data, shacl_graph=graph, meta_shacl=True)[0] + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("property_name", ["creator", "contributor", "publisher", "rightsHolder"]) +@pytest.mark.parametrize( + "range_name,value", + [ + ("nodeidentifier", "ex:team"), + ("nodeidentifier", "https://example.org/team"), + ("string", "The Example Team"), + ("string", "https://example.org/team"), + ], +) +def test_dcmi_agent_annotation_declarations(generator: str, property_name: str, range_name: str, value: str) -> None: + """DCMI agent values follow declarations, including deliberately literal URL text.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["classes"]["Metadata"]["attributes"] = { + "agent": {"slot_uri": "dcterms:" + property_name, "range": range_name} + } + schema["classes"]["Thing"].pop("annotations") + metadata = {"instantiates": ["ex:Profile"], "annotations": {"dcterms:" + property_name: value}} + # OWL annotates the ontology header; SHACL annotates the class shape. + if generator == "owl": + schema.update(metadata) + subject = URIRef(EX + "model") + else: + schema["classes"]["Thing"].update(metadata) + subject = URIRef(EX + "Thing") + graph = _graph(yaml.safe_dump(schema), generator, default_language="en") + predicate = URIRef("http://purl.org/dc/terms/" + property_name) + expected = URIRef(EX + "team") if range_name == "nodeidentifier" else Literal(value, lang="en") + assert set(graph.objects(subject, predicate)) == {expected} + + +def test_declared_contributor_matches_metamodel_field() -> None: + """The standard contributors field and an explicitly declared node annotation agree.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["classes"]["Metadata"]["attributes"]["contributor"] = { + "slot_uri": "dcterms:contributor", + "range": "nodeidentifier", + } + schema.update( + instantiates=["ex:Profile"], + contributors=["ex:team"], + annotations={"contributor": "ex:team"}, + ) + graph = _graph(yaml.safe_dump(schema), "owl") + assert set(graph.objects(URIRef(EX + "model"), URIRef("http://purl.org/dc/terms/contributor"))) == { + URIRef(EX + "team") + } + + +@pytest.mark.parametrize("property_name", ["creator", "contributor", "publisher", "rightsHolder"]) +def test_undeclared_dcmi_agent_url_stays_literal(property_name: str) -> None: + """A familiar DCMI predicate alone does not authorize OWL node coercion.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["annotations"] = {"dcterms:" + property_name: EX + "team"} + graph = _graph(yaml.safe_dump(schema), "owl") + assert set(graph.objects(URIRef(EX + "model"), URIRef("http://purl.org/dc/terms/" + property_name))) == { + Literal(EX + "team") + } diff --git a/tests/linkml/test_generators/test_generator_feature_integration.py b/tests/linkml/test_generators/test_generator_feature_integration.py new file mode 100644 index 0000000000..6c069b145a --- /dev/null +++ b/tests/linkml/test_generators/test_generator_feature_integration.py @@ -0,0 +1,92 @@ +"""Consumer checks for features that overlap on the integration branch.""" + +import pytest +from pyshacl import validate +from rdflib import RDF, Graph, Namespace + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator + +EX = Namespace("https://example.org/integration/") + + +@pytest.mark.parametrize("diff_stable", [False, True]) +@pytest.mark.parametrize( + "value,details,expected", + [ + ("ex:allowed", 'ex:allowed a ex:Resource ; ex:category "ok" .', True), + ("ex:other", 'ex:other a ex:Resource ; ex:category "ok" .', False), + ("ex:allowed", 'ex:allowed a ex:Resource ; ex:category "wrong" .', False), + ("ex:allowed", "ex:allowed a ex:Resource .", False), + ('"literal"', "", True), + ('"other"', "", False), + ], +) +def test_any_of_combines_pattern_and_range_expression( + diff_stable: bool, value: str, details: str, expected: bool +) -> None: + """Each branch enforces its pattern and nested constraints together after RDF serialization.""" + schema = """ +id: https://example.org/integration +name: integration +prefixes: + ex: https://example.org/integration/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Thing: + attributes: + value: + any_of: + - range: Resource + pattern: /allowed$ + range_expression: + slot_conditions: + category: {equals_string: ok, required: true} + - range: string + pattern: ^literal$ + Resource: + attributes: + category: {range: string} +""" + shapes = ShaclGenerator(schema, closed=False, diff_stable=diff_stable).serialize() + data = f"@prefix ex: <{EX}> . ex:thing a ex:Thing ; ex:value {value} . {details}" + conforms, _, report = validate( + data_graph=data, + data_graph_format="turtle", + shacl_graph=shapes, + shacl_graph_format="turtle", + meta_shacl=True, + ) + assert conforms is expected, report + + +@pytest.mark.parametrize("diff_stable", [False, True]) +def test_type_keeps_instantiates_and_declared_annotation(diff_stable: bool) -> None: + """A local type retains its metaclass membership and its declared IRI-valued annotation.""" + schema = """ +id: https://example.org/integration +name: integration +prefixes: + ex: https://example.org/integration/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Metadata: + class_uri: ex:Metadata + attributes: + reference: + slot_uri: ex:reference + range: nodeidentifier +types: + Label: + typeof: string + instantiates: [ex:Metadata] + annotations: + reference: ex:target +""" + graph = Graph().parse(data=OwlSchemaGenerator(schema, diff_stable=diff_stable).serialize(), format="turtle") + assert (EX.Label, RDF.type, EX.Metadata) in graph + assert (EX.Label, EX.reference, EX.target) in graph diff --git a/tests/linkml/test_generators/test_has_member.py b/tests/linkml/test_generators/test_has_member.py new file mode 100644 index 0000000000..63ddc02c60 --- /dev/null +++ b/tests/linkml/test_generators/test_has_member.py @@ -0,0 +1,277 @@ +"""``has_member`` asks for a value satisfying an expression alike in the JSON Schema and SHACL generators. + +A member is one value of a multivalued slot, so a slot without a value, absent +or empty, has no member. In a condition of a class-level expression, +``has_member`` therefore decides whether the slot may be absent, and its member +takes the form of the condition. The agreement tests validate the same +instance with both generated artifacts, loading it as RDF through the +generated JSON-LD context. +""" + +import json +import logging +from typing import Any + +import pytest +import yaml +from jsonschema import Draft201909Validator +from pyshacl import validate + +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator +from tests.linkml.utils import generator_agreement + +PREFIX = "@prefix ex: . " + + +@pytest.fixture +def schema() -> dict[str, Any]: + """A collection of tags, scores and inlined items.""" + return yaml.safe_load(""" +id: https://example.org/membership +name: membership +prefixes: + ex: https://example.org/membership/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Collection: + tree_root: true + slots: [tags, scores, items] + Item: + slots: [status] +slots: + tags: {range: string, multivalued: true} + scores: {range: integer, multivalued: true} + items: {range: Item, multivalued: true, inlined_as_list: true} + status: {range: string} +""") + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any]) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of ``Collection``.""" + return generator_agreement.verdicts(schema, instance, "Collection") + + +def conforms(schema: dict[str, Any], data: str) -> bool: + """Whether Turtle *data* conforms to the SHACL shapes generated for *schema*, which must pass meta-SHACL.""" + shapes = ShaclGenerator(yaml.safe_dump(schema), closed=False).serialize() + return validate( + data_graph=PREFIX + data, + data_graph_format="turtle", + shacl_graph=shapes, + shacl_graph_format="turtle", + meta_shacl=True, + )[0] + + +def json_valid(schema: dict[str, Any], instance: dict[str, Any]) -> bool: + """Whether the JSON Schema generated for *schema* accepts *instance* of ``Collection``.""" + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Collection").serialize()) + return Draft201909Validator(json_schema).is_valid(instance) + + +_READY = {"range_expression": {"slot_conditions": {"status": {"equals_string": "ready"}}}} + + +@pytest.mark.parametrize( + "slot,member,values,valid", + [ + pytest.param("tags", {"equals_string": "reviewed"}, ["reviewed", "pending"], True, id="match"), + pytest.param("tags", {"equals_string": "reviewed"}, ["pending"], False, id="no-match"), + # a slot without a value has no member + pytest.param("tags", {"equals_string": "reviewed"}, [], False, id="empty"), + pytest.param("tags", {"equals_string": "reviewed"}, None, False, id="absent"), + pytest.param("tags", {}, ["anything"], True, id="any-value"), + pytest.param("tags", {}, None, False, id="any-value-absent"), + pytest.param("tags", {"equals_string_in": ["a", "b"], "pattern": "^a"}, ["a", "b"], True, id="conjunction"), + pytest.param("tags", {"equals_string_in": ["a", "b"], "pattern": "^a"}, ["b"], False, id="conjunction-other"), + pytest.param("scores", {"minimum_value": 10}, [9, 10], True, id="bound"), + pytest.param("scores", {"minimum_value": 10}, [8, 9], False, id="bound-other"), + # two values of one SHACL parameter hold together + pytest.param("scores", {"minimum_value": 0, "equals_number": 0}, [-1, 0], True, id="repeated"), + pytest.param("scores", {"minimum_value": 0, "equals_number": 0}, [-1, 1], False, id="repeated-other"), + pytest.param("scores", {"minimum_value": 5, "equals_number": 3}, [3, 5], False, id="contradiction"), + pytest.param("scores", {"minimum_value": 5, "equals_number": 7}, [6], False, id="repeated-second"), + pytest.param("items", _READY, [{"status": "draft"}, {"status": "ready"}], True, id="range_expression"), + pytest.param("items", _READY, [{"status": "draft"}], False, id="range_expression-other"), + # a value whose status is unknown is not definitely no member + pytest.param("items", _READY, [{"status": "draft"}, {}], True, id="range_expression-unknown"), + ], +) +def test_slot_has_member( + schema: dict[str, Any], slot: str, member: dict[str, Any], values: list | None, valid: bool +) -> None: + """At least one value of the slot must satisfy the member expression; the others need not.""" + schema["slots"][slot]["has_member"] = member + assert verdicts(schema, {} if values is None else {slot: values}) == (valid, valid) + + +_REVIEWED = {"has_member": {"equals_string": "reviewed"}} + + +@pytest.mark.parametrize( + "expression,values,valid", + [ + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, None, False, id="all_of-absent"), + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["reviewed"], True, id="all_of"), + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending"], False, id="all_of-other"), + # has_member decides presence, so it is false, not unknown, for an absent slot + pytest.param({"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, None, True, id="none_of-absent"), + pytest.param({"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending"], True, id="none_of-other"), + pytest.param( + {"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending", "reviewed"], False, id="none_of" + ), + pytest.param( + {"any_of": [{"slot_conditions": {"tags": _REVIEWED}}, {"slot_conditions": {"scores": {"required": True}}}]}, + None, + False, + id="any_of-absent", + ), + ], +) +def test_has_member_in_class_expression( + schema: dict[str, Any], expression: dict[str, Any], values: list | None, valid: bool +) -> None: + """In a condition of a class-level expression, ``has_member`` is false for a slot without a value.""" + schema["classes"]["Collection"].update(expression) + assert verdicts(schema, {} if values is None else {"tags": values}) == (valid, valid) + + +@pytest.mark.parametrize( + "items,valid", + [ + pytest.param([{}], True, id="unknown"), + pytest.param([{"status": "draft"}], True, id="other"), + pytest.param([{"status": "draft"}, {"status": "ready"}], False, id="match"), + ], +) +def test_member_under_negation_takes_the_definite_form(schema: dict[str, Any], items: list, valid: bool) -> None: + """Under ``none_of``, a member must definitely satisfy its expression to count.""" + schema["classes"]["Collection"]["none_of"] = [{"slot_conditions": {"items": {"has_member": _READY}}}] + assert verdicts(schema, {"items": items}) == (valid, valid) + + +@pytest.mark.parametrize("scores,valid", [([5], True), ([-1, 11], True), ([11], False), ([-1], False)]) +def test_members_of_two_conditions_may_be_one_value(schema: dict[str, Any], scores: list[int], valid: bool) -> None: + """Each ``has_member`` asks for a value of its own expression, which may or may not be the same value.""" + schema["classes"]["Collection"]["all_of"] = [ + {"slot_conditions": {"scores": {"has_member": {"minimum_value": 0}}}}, + {"slot_conditions": {"scores": {"has_member": {"maximum_value": 10}}}}, + ] + assert verdicts(schema, {"scores": scores}) == (valid, valid) + + +@pytest.mark.parametrize("statuses,valid", [(["draft", "ready"], True), (["draft"], False), ([], False)]) +@pytest.mark.parametrize("in_class_expression", [False, True]) +def test_has_member_on_values_inlined_as_dict( + schema: dict[str, Any], statuses: list[str], valid: bool, in_class_expression: bool +) -> None: + """The members of a slot inlined as a dict are the values of the dict.""" + schema["classes"]["Item"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + del schema["slots"]["items"]["inlined_as_list"] + schema["slots"]["items"]["inlined"] = True + if in_class_expression: + schema["classes"]["Collection"]["all_of"] = [{"slot_conditions": {"items": {"has_member": _READY}}}] + else: + schema["slots"]["items"]["has_member"] = _READY + items = {f"i{n}": {"status": status} for n, status in enumerate(statuses)} + assert json_valid(schema, {"items": items}) is valid + data = "ex:c a ex:Collection" + "".join(f"; ex:items ex:{key}" for key in items) + " . " + data += "".join(f'ex:{key} a ex:Item; ex:status "{value["status"]}" . ' for key, value in items.items()) + assert conforms(schema, data) is valid + + +@pytest.mark.parametrize("typed,valid", [(True, True), (False, False)]) +def test_member_range_needs_the_class(schema: dict[str, Any], typed: bool, valid: bool) -> None: + """A member with a class ``range`` must be an instance of that class.""" + schema["classes"]["Special"] = {"is_a": "Item", "slot_usage": {"status": {"required": True}}} + schema["slots"]["items"]["has_member"] = {"range": "Special"} + special = "ex:Special, " if typed else "" + assert conforms(schema, f'ex:c a ex:Collection; ex:items [a {special}ex:Item; ex:status "ready"] .') is valid + assert json_valid(schema, {"items": [{"status": "ready"}]}) + assert not json_valid(schema, {"items": [{}]}) + + +@pytest.mark.parametrize("dict_inlined", [False, True]) +def test_null_has_no_member(schema: dict[str, Any], dict_inlined: bool) -> None: + """A JSON ``null`` is no value, so it has no member, although the generator otherwise allows ``null``.""" + if dict_inlined: + schema["classes"]["Item"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + del schema["slots"]["items"]["inlined_as_list"] + schema["slots"]["items"]["inlined"] = True + schema["slots"]["items"]["has_member"] = {} + assert not json_valid(schema, {"items": None}) + del schema["slots"]["items"]["has_member"] + assert json_valid(schema, {"items": None}) + + +@pytest.mark.parametrize("grade,valid", [(3, True), (1, False)]) +def test_member_range_resolves_its_range_expression(schema: dict[str, Any], grade: int, valid: bool) -> None: + """The ``range_expression`` of a member with a ``range`` constrains the slots of that range class.""" + schema["classes"]["Special"] = {"is_a": "Item", "attributes": {"grade": {"range": "integer"}}} + expression = {"slot_conditions": {"grade": {"minimum_value": 2}}} + schema["slots"]["items"]["has_member"] = {"range": "Special", "range_expression": expression} + assert conforms(schema, f"ex:c a ex:Collection; ex:items [a ex:Item, ex:Special; ex:grade {grade}] .") is valid + + +@pytest.mark.parametrize( + "slot_change,member,reason", + [ + pytest.param( + {"multivalued": False}, + {"equals_string": "reviewed"}, + "has_member on slot 'tags', which is not multivalued", + id="single-valued", + ), + pytest.param( + {}, + {"minimum_cardinality": 1}, + "'minimum_cardinality' in the has_member of slot 'tags'", + id="cardinality-of-a-value", + ), + pytest.param( + {}, + {"any_of": [{"equals_string": "reviewed"}]}, + "'any_of' in the has_member of slot 'tags'", + id="slot-operator", + ), + pytest.param( + {"range": "integer"}, + {"equals_string": "5"}, + "equals_string in the has_member of slot 'tags', whose range 'integer' does not hold strings", + id="string-on-integer", + ), + ], +) +def test_untranslatable_has_member_skipped_with_warning( + schema: dict[str, Any], + caplog: pytest.LogCaptureFixture, + slot_change: dict[str, Any], + member: dict[str, Any], + reason: str, +) -> None: + """A ``has_member`` SHACL cannot express is left out of the shapes, with one warning.""" + schema["slots"]["tags"].update(slot_change, has_member=member) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, "ex:c a ex:Collection .") + assert [r.getMessage() for r in caplog.records] == [ + f"Slot 'tags': has_member is not translated to SHACL, because it uses {reason} (in the shapes of 'Collection')." + ] + + +def test_untranslatable_has_member_in_class_expression_skips_it( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A class-level expression whose ``has_member`` cannot be translated is skipped as a whole.""" + member = {"any_of": [{"equals_string": "reviewed"}]} + schema["classes"]["Collection"]["all_of"] = [{"slot_conditions": {"tags": {"has_member": member}}}] + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, "ex:c a ex:Collection .") + assert [r.getMessage() for r in caplog.records] == [ + "Class 'Collection': all_of is not translated to SHACL, because it uses " + "'any_of' in the has_member of slot 'tags'." + ] diff --git a/tests/linkml/test_generators/test_javagen.py b/tests/linkml/test_generators/test_javagen.py index fee15e005b..a9d04165c4 100644 --- a/tests/linkml/test_generators/test_javagen.py +++ b/tests/linkml/test_generators/test_javagen.py @@ -483,10 +483,87 @@ def test_cli_config_file_malformed_section_errors(tmp_path, config_yaml, where): assert f"expected a YAML mapping at {where}" in result.output +def test_cli_config_file_sets_a_repeatable_option(tmp_path): + """A repeatable option (`--visitor`) accepts a YAML scalar as a one-element list. + Treated as a bare string it would be iterated per character, generating a visitor + per letter instead of the one that was asked for.""" + schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") + config_path = tmp_path / "myconfig.yaml" + config_path.write_text("generator_args:\n java:\n package: org.example\n visitor: Thing\n") + out_dir = tmp_path / "out" + + result = CliRunner().invoke( + cli, + ["--config-file", str(config_path), "--output-directory", str(out_dir), str(schema_path)], + ) + + assert result.exit_code == 0, result.output + assert (out_dir / "IThingVisitor.java").exists() + assert [p.name for p in out_dir.glob("I*Visitor.java")] == ["IThingVisitor.java"] + + +def test_cli_config_file_typed_option_is_converted(tmp_path): + """A config value for a `click.Path` option arrives as a Path, like it would from the + command line; as a raw str it reaches the generator and fails on the first Path call.""" + schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") + template_dir = tmp_path / "templates" + template_dir.mkdir() + config_path = tmp_path / "myconfig.yaml" + config_path.write_text(f"generator_args:\n java:\n package: org.example\n template_dir: {template_dir}\n") + out_dir = tmp_path / "out" + + result = CliRunner().invoke( + cli, + ["--config-file", str(config_path), "--output-directory", str(out_dir), str(schema_path)], + ) + + assert result.exit_code == 0, result.output + assert_file_contains(out_dir / "Thing.java", "public class Thing", after="package org.example") + + +@pytest.mark.parametrize("via_config", [False, True]) +def test_cli_missing_template_file_is_a_usage_error(tmp_path, via_config): + """A --template-file that doesn't exist is reported like a missing --template-dir: a + usage error naming the option, not a FileNotFoundError traceback. A config-file value + goes through the same click type, so it is checked the same way.""" + schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") + missing = tmp_path / "missing.jinja2" + if via_config: + config_path = tmp_path / "myconfig.yaml" + config_path.write_text(f"generator_args:\n java:\n template_file: {missing}\n") + extra = ["--config-file", str(config_path)] + else: + extra = ["--template-file", str(missing)] + + result = CliRunner().invoke(cli, [*extra, "--output-directory", str(tmp_path / "out"), str(schema_path)]) + + assert result.exit_code == 2 + assert "--template-file" in result.output + assert "does not exist" in result.output + + +def test_cli_config_file_unknown_key_is_warned_not_injected(tmp_path, caplog): + """A key click never exposes (`version`, from --version) is a config typo: warn and + skip it, rather than passing a kwarg the generator's __init__ would reject.""" + schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") + config_path = tmp_path / "myconfig.yaml" + config_path.write_text("generator_args:\n java:\n package: org.example\n version: '1.2'\n") + out_dir = tmp_path / "out" + + with caplog.at_level(logging.WARNING, logger="linkml.utils.generator"): + result = CliRunner().invoke( + cli, + ["--config-file", str(config_path), "--output-directory", str(out_dir), str(schema_path)], + ) + + assert result.exit_code == 0, result.output + assert any("version" in rec.getMessage() for rec in caplog.records) + + def test_cli_config_file_real_project_config_shape(tmp_path): """gen-java's --config-file accepts a full, real-world gen-project config.yaml - (other generators' sections, excludes/includes, etc.) and only reads - generator_args.java.package out of it, ignoring the rest.""" + (other generators' sections, excludes/includes, etc.), reading only its own + generator_args.java section and ignoring the other generators'.""" schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") config_path = tmp_path / "config.yaml" config_path.write_text( diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py index 8aefc013b1..2672bddcb4 100644 --- a/tests/linkml/test_generators/test_jsonldcontextgen.py +++ b/tests/linkml/test_generators/test_jsonldcontextgen.py @@ -4,6 +4,7 @@ import pytest from click.testing import CliRunner +from rdflib import Graph, Literal, URIRef from linkml.generators import ContextGenerator, JSONLDGenerator from linkml.generators.jsonldcontextgen import ContextGenerator as FrameContextGenerator @@ -1290,8 +1291,8 @@ def test_xsd_anyuri_as_iri_owl_curie_unchanged(): ``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()``. + already excludes ``curie``, and both generators decide by + ``is_xsd_anyuri_range()``. """ from rdflib import OWL, RDF, URIRef @@ -1669,3 +1670,238 @@ def test_kitchen_sink_employment_event_type_falls_back(kitchen_sink_path): slot_def = ctx["employed_at"] if isinstance(slot_def, dict) and "@context" in slot_def: assert "@vocab" not in slot_def.get("@context", {}) + + +@pytest.mark.parametrize("use_curies", [True, False]) +def test_identifier_slot_aliases_id(tmp_path, use_curies): + """Identifier slots without slot_uri must alias @id. + + The context key must be the slot name (term used in instance data), NOT a + CURIE, even with --use-curies enabled. An identifier slot's value is the + node's subject IRI and does not produce a predicate triple. + + This is a regression test for issue #4056. + """ + schema_file = tmp_path / "identifier_test.yaml" + schema_file.write_text( + textwrap.dedent( + """ id: https://example.org/identifier-test + name: identifier-test + prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/identifier-test/ + imports: + - linkml:types + default_prefix: ex + default_range: string + classes: + MyClass: + attributes: + id: + identifier: true + range: string + required: true + """ + ) + ) + generator = ContextGenerator(str(schema_file), mergeimports=True, use_curies=use_curies) + ctx = json.loads(generator.serialize())["@context"] + assert ctx.get("id") == "@id" + # no CURIE alias should be emitted for the identifier + assert not any(k.endswith(":id") for k in ctx) + + +@pytest.mark.parametrize("use_curies", [True, False]) +def test_identifier_slot_with_slot_uri_aliases_id(tmp_path, use_curies): + """Identifier slots with slot_uri must still alias @id (slot_uri is ignored). + + Even when a slot_uri is declared on an identifier slot, the context must + still map the slot name to @id and NOT emit the slot_uri as an alias. + This is consistent with LinkML's RDF semantics: identifier values become + the node subject IRI only; no predicate triple is emitted. + """ + schema_file = tmp_path / "identifier_slot_uri_test.yaml" + schema_file.write_text( + textwrap.dedent( + """ id: https://example.org/identifier-slot-uri-test + name: identifier-slot-uri-test + prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/identifier-slot-uri-test/ + sh: http://www.w3.org/ns/shacl# + imports: + - linkml:types + default_prefix: ex + default_range: string + classes: + MyClass: + attributes: + id: + identifier: true + range: string + required: true + slot_uri: sh:focusNode + """ + ) + ) + generator = ContextGenerator(str(schema_file), mergeimports=True, use_curies=use_curies) + ctx = json.loads(generator.serialize())["@context"] + assert ctx.get("id") == "@id" + # no slot_uri key should appear, even with use_curies + assert "sh:focusNode" not in ctx + assert "focusNode" not in ctx + # no CURIE alias should be emitted for the identifier + assert not any(k.endswith(":id") for k in ctx) + + +@pytest.mark.parametrize("use_curies", [True, False]) +def test_identifier_context_round_trips_to_rdf(tmp_path, use_curies): + """Data keyed per the generated context yields triples with the identifier as subject. + + Regression test for https://github.com/linkml/linkml/issues/4056: a CURIE-keyed + ``@id`` alias is invalid JSON-LD 1.1, and rdflib silently produced no triples. + """ + schema_file = tmp_path / "identifier_test.yaml" + schema_file.write_text( + textwrap.dedent( + """ id: https://example.org/identifier-test + name: identifier-test + prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/identifier-test/ + imports: + - linkml:types + default_prefix: ex + default_range: string + classes: + MyClass: + attributes: + id: + identifier: true + name: {} + """ + ) + ) + ctx = json.loads(ContextGenerator(str(schema_file), mergeimports=True, use_curies=use_curies).serialize()) + name_key = "ex:name" if use_curies else "name" + doc = {**ctx, "id": "ex:thing1", name_key: "n"} + + graph = Graph().parse(data=json.dumps(doc), format="json-ld") + + ex = "https://example.org/identifier-test/" + assert set(graph) == {(URIRef(ex + "thing1"), URIRef(ex + "name"), Literal("n"))} + + +def test_normalize_prefixes_renames_nonstandard_alias(tmp_path): + """When --normalize-prefixes is set, non-standard aliases are replaced by rdflib defaults. + + rdflib binds ``dc`` to ``http://purl.org/dc/elements/1.1/`` by default. + A schema that declares ``dce`` for the same URI should have it normalised + to ``dc`` when the flag is enabled. + + See: rdflib default namespace bindings. + """ + schema = tmp_path / "schema.yaml" + schema.write_text( + """\ +id: https://example.org/test +name: test_normalize +default_prefix: ex +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + dce: http://purl.org/dc/elements/1.1/ +imports: + - linkml:types +classes: + Record: + class_uri: ex:Record + attributes: + title: + range: string + slot_uri: dce:title +""", + encoding="utf-8", + ) + + # Flag OFF (default): non-standard alias preserved + ctx_off = json.loads(ContextGenerator(str(schema), normalize_prefixes=False).serialize())["@context"] + assert "dce" in ctx_off, "With flag off, original prefix 'dce' must be preserved" + + # Flag ON: rdflib default name used + ctx_on = json.loads(ContextGenerator(str(schema), normalize_prefixes=True).serialize())["@context"] + assert "dc" in ctx_on, "With flag on, 'dce' should be normalised to 'dc'" + assert "dce" not in ctx_on, "With flag on, original alias 'dce' should be removed" + assert ctx_on["dc"] == "http://purl.org/dc/elements/1.1/" + + +def test_normalize_prefixes_default_is_off(tmp_path): + """The --normalize-prefixes flag defaults to False — no prefix renaming. + + Ensures backward compatibility: existing schemas produce identical output. + """ + schema = tmp_path / "schema.yaml" + schema.write_text( + """\ +id: https://example.org/test +name: test_default +default_prefix: ex +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + sdo: https://schema.org/ +imports: + - linkml:types +classes: + Thing: + class_uri: sdo:Thing + attributes: + name: + range: string + slot_uri: sdo:name +""", + encoding="utf-8", + ) + + ctx = json.loads(ContextGenerator(str(schema)).serialize())["@context"] + # Without the flag, the schema's own prefix name must be preserved + assert "sdo" in ctx, "Default behavior must preserve schema-declared prefix 'sdo'" + + +def test_normalize_prefixes_curie_remapping(tmp_path): + """CURIEs in element @id values use the normalised prefix name. + + When ``sdo`` is normalised to ``schema``, slot URIs like ``sdo:name`` + must appear as ``schema:name`` in the generated context. + """ + schema = tmp_path / "schema.yaml" + schema.write_text( + """\ +id: https://example.org/test +name: test_curie +default_prefix: ex +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + sdo: https://schema.org/ +imports: + - linkml:types +classes: + Person: + class_uri: sdo:Person + attributes: + full_name: + range: string + slot_uri: sdo:name +""", + encoding="utf-8", + ) + + ctx = json.loads(ContextGenerator(str(schema), normalize_prefixes=True).serialize())["@context"] + # The prefix declaration must use the standard name + assert "schema" in ctx, "Normalised prefix 'schema' must appear" + # Element @id must use the normalised prefix + person = ctx.get("Person", {}) + assert person.get("@id", "").startswith("schema:"), ( + f"Person @id should use normalised prefix 'schema:', got {person}" + ) diff --git a/tests/linkml/test_generators/test_jsonldgen.py b/tests/linkml/test_generators/test_jsonldgen.py index e0eead3a11..dcf3210978 100644 --- a/tests/linkml/test_generators/test_jsonldgen.py +++ b/tests/linkml/test_generators/test_jsonldgen.py @@ -2,10 +2,13 @@ import logging import pytest +from click.testing import CliRunner from rdflib import Graph, term from yaml import safe_load from linkml.generators import JSONLDGenerator, RDFGenerator +from linkml.generators.jsonldgen import cli as jsonld_cli +from linkml.generators.yamlgen import YAMLGenerator from linkml_runtime.utils.schemaview import SchemaView logger = logging.getLogger(__name__) @@ -14,7 +17,7 @@ # network: rdflib fetches the @context URLs in the generated JSON-LD @pytest.mark.network def test_class_uri(input_path): - """`class_uri` should result in an `skos:exactMatch` relationship + """`class_uri` should be kept and also result in an `skos:exactMatch` relationship in the generated JSON-LD and RDF.""" # focusing on the class `Thing` of the schema jsonld_context_class_uri_prefix.yaml: # [...] @@ -42,6 +45,7 @@ def test_class_uri(input_path): # "name": "Thing", # "definition_uri": "http://uri.interlex.org/tgbugs/uris/readable/sparc/Thing", # "from_schema": "https://sparc.olympiangods.org/sparcur/schemas/1/sparc", + # "class_uri": "http://www.w3.org/2000/01/rdf-schema#Resource", # "exact_mappings": [ # "rdfs:Resource" # ], @@ -56,7 +60,7 @@ def test_class_uri(input_path): # check each of the schema classes (according the SchemaView) for class_name, class_info in classes_with_custom_uri.items(): assert class_info["uri"] in classes_jsonld.keys() - assert "class_uri" not in classes_jsonld[class_info["uri"]].keys() + assert classes_jsonld[class_info["uri"]]["class_uri"] == schema_view.expand_curie(class_info["class_uri"]) assert "exact_mappings" in classes_jsonld[class_info["uri"]].keys() assert class_info["class_uri"] in classes_jsonld[class_info["uri"]]["exact_mappings"] @@ -65,6 +69,7 @@ def test_class_uri(input_path): # a linkml:ClassDefinition ; # skos:inScheme ; # skos:exactMatch rdfs:Resource ; + # linkml:class_uri rdfs:Resource ; # linkml:definition_uri ; # linkml:slot_usage [ ] . # [...] @@ -89,10 +94,129 @@ def test_class_uri(input_path): single_item = class_properties[str(p)] class_properties[str(p)] = [single_item] class_properties[str(p)].append(str(o)) - assert "https://w3id.org/linkml/class_uri" not in class_properties.keys() + assert class_properties["https://w3id.org/linkml/class_uri"] == schema_view.expand_curie( + class_info["class_uri"] + ) assert "http://www.w3.org/2004/02/skos/core#exactMatch" in class_properties.keys() assert schema_view.expand_curie(class_info["class_uri"]) == schema_view.expand_curie( class_properties["http://www.w3.org/2004/02/skos/core#exactMatch"] ) or schema_view.expand_curie(class_info["class_uri"]) in schema_view.expand_curie( class_properties["http://www.w3.org/2004/02/skos/core#exactMatch"] ) + + +CLASS_URI_SCHEMA = """ +id: https://example.org/class-uri-test +name: class-uri-test +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/class-uri-test/ + owl: http://www.w3.org/2002/07/owl# +imports: + - linkml:types +default_prefix: ex +default_range: string +classes: + Mapping: + class_uri: owl:Axiom + MappingSet: {} +""" + + +def test_class_uri_kept_alongside_exact_mapping(tmp_path): + """An explicit `class_uri` is kept and added to `exact_mappings`; an implicit one adds no mapping. + + Regression test for https://github.com/linkml/linkml/issues/4066: the JSON-LD + schema dropped `class_uri`, so it no longer round-tripped, and every class + without an explicit `class_uri` gained an `exact_mappings` entry to itself. + """ + schema_path = tmp_path / "class_uri_test.yaml" + schema_path.write_text(CLASS_URI_SCHEMA) + + jsonld = json.loads(JSONLDGenerator(str(schema_path), format="jsonld").serialize()) + classes = {cls["name"]: cls for cls in jsonld["classes"]} + assert classes["Mapping"]["class_uri"] == "http://www.w3.org/2002/07/owl#Axiom" + assert classes["Mapping"]["exact_mappings"] == ["owl:Axiom"] + assert classes["MappingSet"]["class_uri"] == "https://example.org/class-uri-test/MappingSet" + assert "exact_mappings" not in classes["MappingSet"] + + merged = safe_load(YAMLGenerator(str(schema_path)).serialize()) + assert merged["classes"]["Mapping"]["exact_mappings"] == ["owl:Axiom"] + assert "exact_mappings" not in merged["classes"]["MappingSet"] + + +CORE_SCHEMA = """ +id: https://example.org/core +name: core +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/tiny/ +imports: + - linkml:types +default_prefix: ex +default_range: string +classes: + Base: + attributes: + label: {} +""" + +TINY_SCHEMA = """ +id: https://example.org/tiny +name: tiny +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/tiny/ +imports: + - linkml:types + - ./core +default_prefix: ex +default_range: string +classes: + Thing: + is_a: Base + attributes: + name: {} +""" + + +@pytest.mark.parametrize( + "make_output", + [ + pytest.param( + lambda schema_dir: CliRunner().invoke(jsonld_cli, ["--no-metadata", "dist/tiny.yaml"]).output, + id="cli-relative-path", + ), + pytest.param( + lambda schema_dir: JSONLDGenerator("dist/tiny.yaml", metadata=False).serialize(), + id="api-relative-path", + ), + pytest.param( + lambda schema_dir: JSONLDGenerator("tiny.yaml", base_dir="dist", metadata=False).serialize(), + id="api-relative-base-dir", + ), + pytest.param( + lambda schema_dir: JSONLDGenerator("tiny.yaml", base_dir=str(schema_dir), metadata=False).serialize(), + id="api-absolute-base-dir", + ), + ], +) +def test_relative_schema_locations(tmp_path, monkeypatch, make_output): + """`gen-jsonld` finds the schema and its relative imports however its location is given. + + Regression test: the nested ContextGenerator was handed the schema path together with + the base_dir SchemaLoader derived from it, so ``dist/tiny.yaml`` was looked up as + ``dist/dist/tiny.yaml``. Forwarding the caller's own base_dir instead must keep an + explicit ``base_dir`` and a sibling ``./core`` import working. + """ + schema_dir = tmp_path / "dist" + schema_dir.mkdir() + (schema_dir / "core.yaml").write_text(CORE_SCHEMA) + (schema_dir / "tiny.yaml").write_text(TINY_SCHEMA) + expected = JSONLDGenerator(str(schema_dir / "tiny.yaml"), metadata=False).serialize() + monkeypatch.chdir(tmp_path) + + output = make_output(schema_dir) + + assert '"name": "Base"' in output + assert output.rstrip() == expected.rstrip() diff --git a/tests/linkml/test_generators/test_jsonschemagen.py b/tests/linkml/test_generators/test_jsonschemagen.py index b22dcc7ae3..631d341f16 100644 --- a/tests/linkml/test_generators/test_jsonschemagen.py +++ b/tests/linkml/test_generators/test_jsonschemagen.py @@ -278,6 +278,12 @@ def test_multivalued_element_constraints(subtests, input_path): external_file_test(subtests, input_path("jsonschema_multivalued_element_constraints.yaml")) +def test_multivalued_has_member(subtests, input_path): + """Tests that has_member asks for a value satisfying its expression, which an empty or absent list lacks.""" + + external_file_test(subtests, input_path("jsonschema_multivalued_has_member.yaml")) + + def test_collection_forms(subtests, input_path): """Tests that expanded, compact, and simple dicts can be validated""" @@ -1785,3 +1791,347 @@ def test_top_class_matches_regardless_of_case(tmp_path): assert schema["additionalProperties"] is False assert "name" in schema["properties"] + + +IDENTIFIER_CURIES_SCHEMA = """ +id: https://example.org/identifier-test +name: identifier-test +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/identifier-test/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + id: + identifier: true + name: {} +classes: + MyClass: + slots: [id, name] + rules: + - preconditions: + slot_conditions: + name: + equals_string: x + postconditions: + slot_conditions: + id: + pattern: "^ex:" +""" + + +@pytest.mark.parametrize("use_curies", [True, False]) +def test_identifier_property_keeps_slot_name(tmp_path, use_curies): + """Identifier properties are keyed by slot name, with or without ``--use-curies``. + + Regression test for https://github.com/linkml/linkml/issues/4056: the identifier + maps to the JSON-LD ``@id`` alias, which cannot be CURIE-keyed, so the JSON Schema + must key it the same way the JSON-LD context does. Other slots still use CURIEs. + """ + schema_file = tmp_path / "identifier_test.yaml" + schema_file.write_text(IDENTIFIER_CURIES_SCHEMA) + generated = json.loads(JsonSchemaGenerator(str(schema_file), use_curies=use_curies).serialize()) + cls_def = generated["$defs"]["ex:MyClass" if use_curies else "MyClass"] + name_key = "ex:name" if use_curies else "name" + + assert set(cls_def["properties"]) == {"id", name_key} + assert cls_def["required"] == ["id"] + assert cls_def["then"]["properties"] == {"id": {"pattern": "^ex:"}} + assert cls_def["then"]["required"] == ["id"] + + instance = {"id": "ex:thing1", name_key: "x"} + jsonschema.validate(instance, {**generated, "$ref": f"#/$defs/{'ex:MyClass' if use_curies else 'MyClass'}"}) + + +_CLASS_EXPRESSION_INHERITANCE_SCHEMA = """ +id: https://example.org/class-expressions +name: class_expressions +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/class-expressions/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + a: {} + b: {} +classes: + Marker: + mixin: true + none_of: + - slot_conditions: + a: + equals_string: forbidden + Parent: + slots: [a, b] + any_of: + - slot_conditions: + a: + required: true + - slot_conditions: + b: + required: true + Child: + is_a: Parent + mixins: [Marker] + GrandChild: + is_a: Child +""" + + +@pytest.mark.parametrize("target_class", ["Parent", "Child", "GrandChild"]) +@pytest.mark.parametrize( + "instance,valid_for_parent,valid_for_marker_users", + [ + pytest.param({"a": "1"}, True, True, id="a"), + pytest.param({"b": "2"}, True, True, id="b"), + pytest.param({}, False, False, id="neither"), + pytest.param({"a": "forbidden"}, True, False, id="forbidden-by-mixin"), + ], +) +def test_class_expressions_apply_to_subclasses(target_class, instance, valid_for_parent, valid_for_marker_users): + """A class expression constrains every instance of its class, so the + definitions of its subclasses, and of the classes using a mixin, carry it.""" + json_schema = json.loads( + JsonSchemaGenerator(_CLASS_EXPRESSION_INHERITANCE_SCHEMA, top_class=target_class).serialize() + ) + expected = valid_for_parent if target_class == "Parent" else valid_for_marker_users + assert jsonschema.Draft7Validator(json_schema).is_valid(instance) == expected + + +_TWO_ABSENT_RULE_SCHEMA = """ +id: https://example.org/absent +name: absent +prefixes: + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_range: string +classes: + Thing: + attributes: + trigger: {} + a: {} + b: {} + rules: + - preconditions: + slot_conditions: + trigger: + value_presence: PRESENT + postconditions: + slot_conditions: + a: + value_presence: ABSENT + b: + value_presence: ABSENT +""" + + +@pytest.mark.parametrize( + "instance,valid", + [ + pytest.param({"trigger": "t"}, True, id="neither"), + pytest.param({"trigger": "t", "a": "x"}, False, id="one"), + pytest.param({"trigger": "t", "a": "x", "b": "y"}, False, id="both"), + pytest.param({"a": "x", "b": "y"}, True, id="not-triggered"), + ], +) +def test_several_absent_slots_each_must_be_absent(instance, valid): + """value_presence: ABSENT on several slots of one expression requires each + to be absent, not merely that they aren't all present.""" + json_schema = json.loads(JsonSchemaGenerator(_TWO_ABSENT_RULE_SCHEMA, top_class="Thing").serialize()) + assert jsonschema.Draft7Validator(json_schema).is_valid(instance) == valid + + +@pytest.mark.parametrize("tags,valid", [(["A"], True), (["A", "B"], False), ([], True)]) +def test_class_expression_condition_uses_the_induced_slot(tags, valid): + """A condition constrains the slot as induced for the class, so a + slot_usage that makes it multivalued applies the condition to every value.""" + schema = """ +id: https://example.org/induced +name: induced +prefixes: + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_range: string +slots: + tags: {} +classes: + Thing: + slots: [tags] + slot_usage: + tags: + multivalued: true + all_of: + - slot_conditions: + tags: + equals_string: A +""" + json_schema = json.loads(JsonSchemaGenerator(schema, top_class="Thing").serialize()) + assert jsonschema.Draft7Validator(json_schema).is_valid({"tags": tags}) == valid + + +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_normalize_prefixes.py b/tests/linkml/test_generators/test_normalize_prefixes.py new file mode 100644 index 0000000000..0a832a5791 --- /dev/null +++ b/tests/linkml/test_generators/test_normalize_prefixes.py @@ -0,0 +1,545 @@ +"""Tests for the --normalize-prefixes flag across all generators. + +Verifies that non-standard prefix aliases (e.g. ``sdo`` for ``https://schema.org/``) +are normalised to well-known names (e.g. ``schema``) consistently in OWL, SHACL, +and JSON-LD context output. + +References: +- prefix.cc — community consensus RDF prefix registry +- rdflib 7.x curated default namespace bindings +- W3C Turtle §2.4 — prefix declarations are syntactic sugar +""" + +import json +import logging +import re +import textwrap + +import pytest + +# ── Shared test schema ────────────────────────────────────────────── + +SCHEMA_SDO = textwrap.dedent("""\ + id: https://example.org/test + name: test_normalize + default_prefix: ex + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + sdo: https://schema.org/ + imports: + - linkml:types + classes: + Person: + class_uri: sdo:Person + attributes: + full_name: + range: string + slot_uri: sdo:name +""") + +SCHEMA_DCE = textwrap.dedent("""\ + id: https://example.org/test + name: test_normalize_dce + default_prefix: ex + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + dce: http://purl.org/dc/elements/1.1/ + imports: + - linkml:types + classes: + Record: + class_uri: ex:Record + attributes: + title: + range: string + slot_uri: dce:title +""") + +# HTTP variant — linkml-runtime historically binds schema: http://schema.org/ +# while rdflib (and the W3C) prefer https://schema.org/. The normalize flag +# must handle both. +SCHEMA_HTTP_SDO = textwrap.dedent("""\ + id: https://example.org/test + name: test_http_schema + default_prefix: ex + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + sdo: http://schema.org/ + imports: + - linkml:types + classes: + Place: + class_uri: sdo:Place + attributes: + geo: + range: string + slot_uri: sdo:geo +""") + +# Collision scenario: user declares 'foaf' for a custom namespace AND 'myfoaf' +# for http://xmlns.com/foaf/0.1/. Normalisation must NOT clobber the user's 'foaf'. +# Uses 'foaf' instead of 'schema' because 'schema' is declared in linkml:types, +# which causes a SchemaLoader merge conflict before normalisation even runs. +SCHEMA_COLLISION = textwrap.dedent("""\ + id: https://example.org/test + name: test_collision + default_prefix: ex + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + foaf: https://something-else.org/ + myfoaf: http://xmlns.com/foaf/0.1/ + imports: + - linkml:types + classes: + Agent: + class_uri: myfoaf:Agent + attributes: + label: + range: string + slot_uri: myfoaf:name +""") + + +def _write_schema(tmp_path, content: str, name: str = "schema.yaml") -> str: + """Write schema content to a temporary file and return its path as string.""" + p = tmp_path / name + p.write_text(content, encoding="utf-8") + return str(p) + + +def _turtle_prefixes(ttl: str) -> dict[str, str]: + """Extract @prefix declarations from Turtle output → {prefix: namespace}.""" + result = {} + for m in re.finditer(r"@prefix\s+(\w+):\s+<([^>]+)>", ttl): + result[m.group(1)] = m.group(2) + return result + + +# ── OWL Generator Tests ───────────────────────────────────────────── + + +def test_owl_sdo_normalised_to_schema(tmp_path): + """sdo → schema when --normalize-prefixes is active.""" + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "schema" in pfx, f"Expected 'schema' prefix in OWL output, got: {sorted(pfx)}" + assert pfx["schema"] == "https://schema.org/" + assert "sdo" not in pfx, "Non-standard 'sdo' prefix should be removed" + + +def test_owl_flag_off_preserves_original(tmp_path): + """Without the flag, schema-declared prefix names are preserved.""" + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=False).serialize() + pfx = _turtle_prefixes(ttl) + assert "sdo" in pfx, "With flag off, original prefix 'sdo' must be preserved" + + +def test_owl_dce_normalised_to_dc(tmp_path): + """dce → dc for http://purl.org/dc/elements/1.1/ in graph bindings. + + Note: rdflib's Turtle serializer only emits @prefix declarations for + namespaces actually used in triples. Since the OWL generator may not + produce triples using dc:elements URIs for simple attribute schemas, + we verify the graph's namespace bindings directly. + """ + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_DCE) + gen = OwlSchemaGenerator(schema_path, normalize_prefixes=True) + graph = gen.as_graph() + bound = {str(p): str(n) for p, n in graph.namespaces()} + assert "dc" in bound, f"Expected 'dc' in graph bindings, got: {sorted(bound)}" + assert bound["dc"] == "http://purl.org/dc/elements/1.1/" + + +def test_owl_custom_prefix_not_affected(tmp_path): + """Domain-specific prefixes (e.g. 'ex') are not touched by normalisation.""" + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "ex" in pfx, "Custom prefix 'ex' must survive normalisation" + assert pfx["ex"] == "https://example.org/" + + +def test_owl_http_schema_org_normalised(tmp_path): + """http://schema.org/ (HTTP variant) also normalises to 'schema'. + + The linkml-runtime historically binds ``schema: http://schema.org/`` + while the W3C and rdflib prefer ``https://schema.org/``. Both + variants must be recognised by the static well-known prefix map. + """ + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_HTTP_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "schema" in pfx, f"Expected 'schema' prefix for http://schema.org/, got: {sorted(pfx)}" + assert "sdo" not in pfx + + +def test_owl_no_schema1_from_runtime_http_binding(tmp_path): + """Runtime-injected ``schema: http://schema.org/`` must not create ``schema1``. + + The linkml metamodel (types.yaml) declares ``schema: http://schema.org/`` + (HTTP). When a user schema declares ``sdo: https://schema.org/`` (HTTPS), + normalisation must clean up *both* variants so the output never contains + auto-generated suffixed prefixes like ``schema1``. + """ + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + suffixed = [p for p in pfx if re.match(r"schema\d+", p)] + assert not suffixed, ( + f"Auto-generated suffixed prefix(es) {suffixed} found — runtime http://schema.org/ binding was not cleaned up" + ) + + +# ── SHACL Generator Tests ─────────────────────────────────────────── + + +def test_shacl_sdo_normalised_to_schema(tmp_path): + """sdo → schema when --normalize-prefixes is active.""" + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "schema" in pfx, f"Expected 'schema' prefix in SHACL output, got: {sorted(pfx)}" + assert pfx["schema"] == "https://schema.org/" + assert "sdo" not in pfx, "Non-standard 'sdo' prefix should be removed" + + +def test_shacl_flag_off_preserves_original(tmp_path): + """Without the flag, schema-declared prefix names are preserved.""" + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = ShaclGenerator(schema_path, normalize_prefixes=False).serialize() + pfx = _turtle_prefixes(ttl) + assert "sdo" in pfx, "With flag off, original prefix 'sdo' must be preserved" + + +def test_shacl_dce_normalised_to_dc(tmp_path): + """dce → dc for http://purl.org/dc/elements/1.1/.""" + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_DCE) + ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "dc" in pfx, f"Expected 'dc' prefix in SHACL output, got: {sorted(pfx)}" + assert pfx["dc"] == "http://purl.org/dc/elements/1.1/" + assert "dce" not in pfx, "Non-standard 'dce' prefix should be removed" + + +def test_shacl_custom_prefix_not_affected(tmp_path): + """Domain-specific prefixes (e.g. 'ex') are not touched by normalisation. + + Note: rdflib only emits @prefix for namespaces used in triples. + We verify graph bindings directly. + """ + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + gen = ShaclGenerator(schema_path, normalize_prefixes=True) + graph = gen.as_graph() + bound = {str(p): str(n) for p, n in graph.namespaces()} + assert "ex" in bound, f"Custom prefix 'ex' must survive in graph bindings, got: {sorted(bound)}" + assert bound["ex"] == "https://example.org/" + + +def test_shacl_http_schema_org_normalised(tmp_path): + """http://schema.org/ (HTTP variant) also normalises to 'schema'.""" + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_HTTP_SDO) + ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "schema" in pfx, f"Expected 'schema' prefix for http://schema.org/, got: {sorted(pfx)}" + assert "sdo" not in pfx + + +def test_shacl_no_schema1_from_runtime_http_binding(tmp_path): + """Runtime-injected ``schema: http://schema.org/`` must not create ``schema1``. + + Same scenario as the OWL test: linkml:types imports bring in + ``schema: http://schema.org/`` while the user schema has + ``sdo: https://schema.org/``. Phase 2 of normalisation must + clean up the orphaned HTTP binding. + """ + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + suffixed = [p for p in pfx if re.match(r"schema\d+", p)] + assert not suffixed, ( + f"Auto-generated suffixed prefix(es) {suffixed} found — runtime http://schema.org/ binding was not cleaned up" + ) + + +# ── JSON-LD Context Generator Tests ───────────────────────────────── + + +def test_context_http_schema_org_normalised(tmp_path): + """http://schema.org/ (HTTP variant) normalises to 'schema' in JSON-LD context. + + This covers the edge case where linkml-runtime's ``schema: http://schema.org/`` + conflicts with rdflib's ``schema: https://schema.org/``. The stale binding + must be removed and replaced with the correct one. + """ + from linkml.generators.jsonldcontextgen import ContextGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_HTTP_SDO) + ctx = json.loads(ContextGenerator(schema_path, normalize_prefixes=True).serialize())["@context"] + assert "schema" in ctx, "HTTP schema.org should normalise to 'schema'" + assert "sdo" not in ctx, "Non-standard 'sdo' should be removed" + # The namespace URI must match the schema-declared one (http, not https) + schema_val = ctx["schema"] + if isinstance(schema_val, dict): + schema_val = schema_val.get("@id", "") + assert schema_val == "http://schema.org/", f"Namespace URI must be preserved: got {schema_val}" + + +# ── Static Prefix Map Tests ───────────────────────────────────────── + + +def test_well_known_prefix_map_returns_dict(): + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + assert isinstance(wk, dict) + assert len(wk) >= 29, f"Expected ≥29 entries, got {len(wk)}" + + +def test_well_known_prefix_map_schema_https(): + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + assert wk["https://schema.org/"] == "schema" + + +def test_well_known_prefix_map_schema_http_variant(): + """Both http and https schema.org must map to 'schema'.""" + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + assert wk["http://schema.org/"] == "schema" + + +def test_well_known_prefix_map_dc_elements(): + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + assert wk["http://purl.org/dc/elements/1.1/"] == "dc" + + +def test_well_known_prefix_map_returns_copy(): + """Callers should not be able to mutate the internal map.""" + from linkml.utils.generator import well_known_prefix_map + + wk1 = well_known_prefix_map() + wk1["http://never-in-any-real-prefix-map.test/"] = "test" + wk2 = well_known_prefix_map() + assert "http://never-in-any-real-prefix-map.test/" not in wk2 + + +def test_well_known_prefix_map_fully_resolved_from_prefixmaps(): + """All rdflib defaults must be resolved from prefixmaps (no residual map). + + This is the proof that pinning prefixmaps to the commit containing + linkml/prefixmaps#81 resolves all well-known prefixes without any + hardcoded fallback. If this test fails after a prefixmaps update, + add the missing prefix to the upstream linked_data.curated.yaml. + """ + from rdflib import Graph as RdfGraph + + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + rdflib_map = {str(ns): str(pfx) for pfx, ns in RdfGraph().namespaces() if str(pfx)} + missing = {ns: pfx for ns, pfx in rdflib_map.items() if ns not in wk} + assert not missing, f"Prefix map missing rdflib defaults (add to prefixmaps upstream): {missing}" + + +# ── Cross-Generator Consistency Tests ──────────────────────────────── + + +def test_all_generators_normalise_sdo_to_schema(tmp_path): + """OWL, SHACL, and JSON-LD context must all use 'schema' for schema.org.""" + from linkml.generators.jsonldcontextgen import ContextGenerator + from linkml.generators.owlgen import OwlSchemaGenerator + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + + owl_ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + shacl_ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + ctx = json.loads(ContextGenerator(schema_path, normalize_prefixes=True).serialize())["@context"] + + owl_pfx = _turtle_prefixes(owl_ttl) + shacl_pfx = _turtle_prefixes(shacl_ttl) + + assert "schema" in owl_pfx, "OWL must use 'schema'" + assert "schema" in shacl_pfx, "SHACL must use 'schema'" + assert "schema" in ctx, "JSON-LD context must use 'schema'" + + assert "sdo" not in owl_pfx, "OWL must not have 'sdo'" + assert "sdo" not in shacl_pfx, "SHACL must not have 'sdo'" + assert "sdo" not in ctx, "JSON-LD context must not have 'sdo'" + + +# ── Prefix Collision Tests ──────────────────────────────────────────── + + +@pytest.mark.parametrize( + "generator_cls,generator_module", + [ + ("OwlSchemaGenerator", "linkml.generators.owlgen"), + ("ShaclGenerator", "linkml.generators.shaclgen"), + ], + ids=["owl", "shacl"], +) +def test_graph_generator_collision_skips_rename(tmp_path, caplog, generator_cls, generator_module): + """Graph generators: myfoaf must NOT be renamed to 'foaf' when user claims that name.""" + import importlib + + mod = importlib.import_module(generator_module) + cls = getattr(mod, generator_cls) + + schema_path = _write_schema(tmp_path, SCHEMA_COLLISION) + with caplog.at_level(logging.WARNING): + gen = cls(schema_path, normalize_prefixes=True) + graph = gen.as_graph() + bound = {str(p): str(n) for p, n in graph.namespaces()} + assert "myfoaf" in bound, "Non-standard 'myfoaf' must remain when collision prevents renaming" + assert bound["myfoaf"] == "http://xmlns.com/foaf/0.1/" + assert "collision" in caplog.text.lower(), f"Expected collision warning, got: {caplog.text}" + + +def test_context_collision_preserves_user_prefix(tmp_path, caplog): + """JSON-LD: user's 'foaf: https://something-else.org/' must survive.""" + from linkml.generators.jsonldcontextgen import ContextGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_COLLISION) + with caplog.at_level(logging.WARNING): + ctx = json.loads(ContextGenerator(schema_path, normalize_prefixes=True).serialize())["@context"] + # User's 'foaf' binding preserved + foaf_val = ctx.get("foaf") + if isinstance(foaf_val, dict): + foaf_val = foaf_val.get("@id", "") + assert foaf_val == "https://something-else.org/", f"User's 'foaf' binding must be preserved, got: {foaf_val}" + # myfoaf must remain (not renamed to foaf) + assert "myfoaf" in ctx, "Non-standard 'myfoaf' must remain when collision prevents renaming" + # Warning emitted + assert "collision" in caplog.text.lower(), f"Expected collision warning, got: {caplog.text}" + + +# ── JSONLDGenerator Flag Forwarding Tests ───────────────────────────── + + +def test_jsonld_generator_forwards_normalize_prefixes(tmp_path): + """JSONLDGenerator must pass normalize_prefixes to embedded ContextGenerator. + + Without forwarding, the inline @context in JSON-LD output would keep + non-standard prefix aliases even when --normalize-prefixes is set. + """ + from linkml.generators.jsonldgen import JSONLDGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + out = JSONLDGenerator(schema_path, normalize_prefixes=True).serialize() + parsed = json.loads(out) + # The @context may be a list; find the dict entry + ctx = parsed.get("@context", {}) + if isinstance(ctx, list): + for item in ctx: + if isinstance(item, dict): + ctx = item + break + assert "sdo" not in ctx, "normalize_prefixes not forwarded: 'sdo' still in embedded @context" + + +# ── Phase 2 HTTP/HTTPS Overwrite Bug Tests ──────────────────────────── + + +def test_phase2_does_not_overwrite_https_with_http(tmp_path): + """When Phase 1 binds schema → https://schema.org/, Phase 2 must not + overwrite it with http://schema.org/ from the runtime metamodel. + + Reproduction: linkml:types imports bring schema: http://schema.org/ + (HTTP) while the user schema has sdo: https://schema.org/ (HTTPS). + Phase 1 normalises sdo → schema (HTTPS). Phase 2 must not then + rebind schema → http://schema.org/ when it encounters the runtime + HTTP binding. + """ + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + gen = OwlSchemaGenerator(schema_path, normalize_prefixes=True) + graph = gen.as_graph() + bound = {str(p): str(n) for p, n in graph.namespaces()} + assert "schema" in bound, f"Expected 'schema' in bindings, got: {sorted(bound)}" + # MUST be HTTPS (from the user's schema), not HTTP (from runtime) + assert bound["schema"] == "https://schema.org/", ( + f"Phase 2 overwrote HTTPS with HTTP: schema bound to {bound['schema']}" + ) + + +def test_normalize_graph_prefixes_phase2_guard(): + """Direct unit test for the Phase 2 guard in normalize_graph_prefixes. + + Simulates the exact scenario: Phase 1 binds schema → https://schema.org/, + then Phase 2 encounters schema1 → http://schema.org/ and must NOT rebind. + """ + from rdflib import Graph, Namespace, URIRef + + from linkml.utils.generator import normalize_graph_prefixes + + g = Graph(bind_namespaces="none") + # Simulate Phase 1 result + g.bind("schema", Namespace("https://schema.org/")) + # Simulate runtime-injected HTTP variant (would appear as schema1) + g.bind("schema1", Namespace("http://schema.org/")) + # Add a triple so the graph isn't empty + g.add((URIRef("https://example.org/s"), URIRef("https://schema.org/name"), URIRef("https://example.org/o"))) + + normalize_graph_prefixes(g, {"sdo": "https://schema.org/"}) + + bound = {str(p): str(n) for p, n in g.namespaces()} + assert bound.get("schema") == "https://schema.org/", f"Phase 2 guard failed: schema bound to {bound.get('schema')}" + + +def test_empty_schema_no_crash(tmp_path): + """A schema with no custom prefixes must not crash normalize_graph_prefixes.""" + from linkml.generators.owlgen import OwlSchemaGenerator + + (tmp_path / "empty.yaml").write_text( + textwrap.dedent("""\ + id: https://example.org/empty + name: empty + default_prefix: ex + prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/ + imports: + - linkml:types + """), + encoding="utf-8", + ) + # Should not raise + gen = OwlSchemaGenerator(str(tmp_path / "empty.yaml"), normalize_prefixes=True) + ttl = gen.serialize() + assert len(ttl) > 0 diff --git a/tests/linkml/test_generators/test_owlgen.py b/tests/linkml/test_generators/test_owlgen.py index bb39294f27..0b88e97ad5 100644 --- a/tests/linkml/test_generators/test_owlgen.py +++ b/tests/linkml/test_generators/test_owlgen.py @@ -9,6 +9,7 @@ from linkml import METAMODEL_CONTEXT_URI from linkml.generators.owlgen import MetadataProfile, OwlSchemaGenerator +from linkml.generators.rdfgen import RDFGenerator from linkml_runtime.linkml_model import SlotDefinition from linkml_runtime.linkml_model.meta import ( AnonymousClassExpression, @@ -1232,3 +1233,124 @@ def test_complement_of_union_of_mixed_none_filters_silently(): # Should succeed and return a BNode (the complement expression). assert result is not None assert isinstance(result, BNode) + + +_INSTANTIATES_SCHEMA = """ +id: http://example.org/test-schema +name: instantiates_test +prefixes: + linkml: https://w3id.org/linkml/ + ex: http://example.org/test-schema/ + vocab: http://example.org/vocab/ +default_prefix: ex +imports: + - linkml:types +enums: + LinkCategory: + implements: + - owl:NamedIndividual + permissible_values: + isLicense: + meaning: ex:isLicense + instantiates: + - vocab:LicenseCategory + isManifest: + meaning: ex:isManifest + instantiates: + - vocab:ManifestCategory + - vocab:Category + isMedia: + meaning: ex:isMedia +""" + +VOCAB = Namespace("http://example.org/vocab/") + + +def test_permissible_value_instantiates_types_the_value() -> None: + """Each ``instantiates`` value of a permissible value becomes an rdf:type of its IRI.""" + g = Graph() + g.parse(data=OwlSchemaGenerator(_INSTANTIATES_SCHEMA, metaclasses=False).serialize(), format="turtle") + + assert (EX.isLicense, RDF.type, VOCAB.LicenseCategory) in g + assert (EX.isManifest, RDF.type, VOCAB.ManifestCategory) in g + assert (EX.isManifest, RDF.type, VOCAB.Category) in g + # the value stays an individual of the enum, which still closes its own class + for pv in (EX.isLicense, EX.isManifest, EX.isMedia): + assert (pv, RDF.type, OWL.NamedIndividual) in g + assert (pv, RDF.type, EX.LinkCategory) in g + one_of = g.value(EX.LinkCategory, OWL.oneOf) + assert set(Collection(g, one_of)) == {EX.isLicense, EX.isManifest, EX.isMedia} + # a value without instantiates gets no further type, and the instantiated classes stay open + assert set(g.objects(EX.isMedia, RDF.type)) == {OWL.NamedIndividual, EX.LinkCategory} + assert g.value(VOCAB.LicenseCategory, OWL.oneOf) is None + + +def test_permissible_value_instantiates_ignored_on_literal(caplog: pytest.LogCaptureFixture) -> None: + """A permissible value rendered as a literal cannot be typed, so instantiates is ignored with a warning.""" + schema = _INSTANTIATES_SCHEMA.replace("owl:NamedIndividual", "rdfs:Literal") + with caplog.at_level(logging.WARNING): + g = Graph() + g.parse(data=OwlSchemaGenerator(schema, metaclasses=False).serialize(), format="turtle") + assert not list(g.triples((None, RDF.type, VOCAB.LicenseCategory))) + assert any("Instantiates on literal isLicense" in rec.message for rec in caplog.records) + + +@pytest.mark.parametrize("metaclasses", [False, True]) +@pytest.mark.parametrize("type_objects", [False, True]) +def test_instantiates_applies_to_emitted_schema_elements(metaclasses: bool, type_objects: bool) -> None: + """Metaclass membership is explicit schema metadata, independent of generated metamodel types.""" + schema = _INSTANTIATES_SCHEMA.replace( + "name: instantiates_test", "name: instantiates_test\ninstantiates: [vocab:SchemaProfile]" + ).replace(" LinkCategory:\n", " LinkCategory:\n instantiates: [vocab:EnumProfile]\n") + schema += """ +classes: + Entry: + instantiates: [vocab:ClassProfile] + slots: [code] +slots: + code: + range: Code + instantiates: [vocab:SlotProfile] +types: + Code: + typeof: string + uri: ex:Code + instantiates: [vocab:TypeProfile] +""" + g = OwlSchemaGenerator(schema, metaclasses=metaclasses, type_objects=type_objects).as_graph() + for resource, metaclass in ( + (URIRef("http://example.org/test-schema"), VOCAB.SchemaProfile), + (EX.Entry, VOCAB.ClassProfile), + (EX.code, VOCAB.SlotProfile), + (EX.Code, VOCAB.TypeProfile), + (EX.LinkCategory, VOCAB.EnumProfile), + (EX.isLicense, VOCAB.LicenseCategory), + ): + assert (resource, RDF.type, metaclass) in g + assert (resource, RDFS.subClassOf, metaclass) not in g + assert (EX.isLicense, RDF.type, VOCAB.EnumProfile) not in g + + +@pytest.mark.parametrize("owl_type", ["owl:NamedIndividual", "owl:Class"]) +@pytest.mark.parametrize("meaning", ["ex:isLicense", "https://example.net/value", None]) +def test_instantiates_uses_the_emitted_value_resource(owl_type: str, meaning: str | None) -> None: + """Explicit and minted IRIs both carry membership, including class/individual punning.""" + schema = _INSTANTIATES_SCHEMA.replace("owl:NamedIndividual", owl_type) + schema = schema.replace(" meaning: ex:isLicense\n", f" meaning: {meaning}\n" if meaning else "") + schema = schema.replace("vocab:LicenseCategory", "https://example.net/Category") + g = OwlSchemaGenerator(schema, metaclasses=False).as_graph() + (resource,) = g.subjects(RDF.type, URIRef("https://example.net/Category")) + assert isinstance(resource, URIRef) + assert (resource, RDFS.label, Literal("isLicense")) in g + assert (resource, RDF.type, OWL[owl_type.split(":")[1]]) in g + if meaning: + assert resource == (EX.isLicense if meaning.startswith("ex:") else URIRef(meaning)) + + +def test_instantiates_rdf_schema_representation_is_distinct_from_owl_assertion() -> None: + """gen-rdf preserves the metamodel predicate; gen-owl interprets it as membership.""" + schema = _INSTANTIATES_SCHEMA.replace("imports:\n - linkml:types\n", "") + rdf = Graph().parse(data=RDFGenerator(schema).serialize(), format="turtle") + owl = OwlSchemaGenerator(schema, metaclasses=False).as_graph() + assert {str(value) for value in rdf.objects(EX.isLicense, LINKML.instantiates)} == {"vocab:LicenseCategory"} + assert (EX.isLicense, RDF.type, VOCAB.LicenseCategory) in owl diff --git a/tests/linkml/test_generators/test_range_expression.py b/tests/linkml/test_generators/test_range_expression.py new file mode 100644 index 0000000000..0b620dc1a4 --- /dev/null +++ b/tests/linkml/test_generators/test_range_expression.py @@ -0,0 +1,447 @@ +"""A slot's ``range_expression`` constrains each of its values alike in the JSON Schema and SHACL generators. + +Each value must satisfy the expression as an instance of its range class +satisfies a class-level expression: its conditions constrain the slots of the +value as induced for that class, and an expression is violated only when it is +definitely false. The agreement tests validate the same instance with both +generated artifacts, loading it as RDF through the generated JSON-LD context. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +import pytest +import yaml +from click.testing import CliRunner +from jsonschema import Draft201909Validator +from pyshacl import validate +from rdflib import Graph, Namespace, URIRef +from rdflib.namespace import SH + +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator, cli +from tests.linkml.utils import generator_agreement + +EX = Namespace("https://example.org/context/") +PREFIX = f"@prefix ex: <{EX}> . " + + +@pytest.fixture +def schema() -> dict[str, Any]: + """A resource is constrained differently when used as a document's license.""" + return yaml.safe_load(""" +id: https://example.org/context +name: context +prefixes: + ex: https://example.org/context/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Document: + tree_root: true + slots: [license] + slot_usage: + license: + range_expression: + slot_conditions: + category: {equals_string: license, required: true} + Resource: + slots: [category, code, tags, details] + Details: + slots: [code] +slots: + license: {range: Resource, inlined: true} + category: {range: string} + code: {range: integer} + tags: {range: string, multivalued: true} + details: {range: Details, inlined: true} +enums: + Category: + permissible_values: + license: {} +""") + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any], target: str = "Document") -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of *target*.""" + return generator_agreement.verdicts(schema, instance, target) + + +def conforms(schema: dict[str, Any], data: str, **options: Any) -> bool: + """Whether Turtle *data* conforms to the SHACL shapes generated for *schema*, which must pass meta-SHACL.""" + shapes = ShaclGenerator(yaml.safe_dump(schema), closed=False, **options).serialize() + return validate( + data_graph=PREFIX + data, + data_graph_format="turtle", + shacl_graph=shapes, + shacl_graph_format="turtle", + meta_shacl=True, + )[0] + + +def _equals(slot: str, value: str | int) -> dict[str, Any]: + """A class expression requiring *slot* to equal *value*, unknown when *slot* is absent.""" + key = "equals_number" if isinstance(value, int) else "equals_string" + return {"slot_conditions": {slot: {key: value}}} + + +_REQUIRED_LICENSE = {"slot_conditions": {"category": {"equals_string": "license", "required": True}}} +_LICENSE_OR_CODE = [_equals("category", "license"), _equals("code", 5)] +_NO_TAGS_AND_LICENSE = { + "slot_conditions": {"tags": {"maximum_cardinality": 0}}, + "all_of": [_equals("category", "license")], +} +_TWO_TAGS = {"slot_conditions": {"tags": {"minimum_cardinality": 2}}} +_CATEGORY_RANGE = {"slot_conditions": {"category": {"range": "Category"}}} +_CODE_FROM_TWO = {"slot_conditions": {"code": {"minimum_value": 2}}} +_REQUIRED_DETAILS = {"slot_conditions": {"details": {"required": True, "range_expression": _CODE_FROM_TWO}}} +_NO_DETAILS_CODE = {"none_of": [{"slot_conditions": {"details": {"range_expression": _CODE_FROM_TWO}}}]} + + +@pytest.mark.parametrize( + "expression,value,valid", + [ + pytest.param(_REQUIRED_LICENSE, {"category": "license"}, True, id="required-match"), + pytest.param(_REQUIRED_LICENSE, {"category": "other"}, False, id="required-other"), + pytest.param(_REQUIRED_LICENSE, {}, False, id="required-absent"), + # a condition that doesn't state presence is unknown for an absent slot + pytest.param(_equals("category", "license"), {}, True, id="optional-absent"), + pytest.param(_equals("category", "license"), {"category": "other"}, False, id="optional-other"), + pytest.param({"none_of": [_equals("category", "license")]}, {}, True, id="none_of-absent"), + pytest.param({"none_of": [_equals("category", "license")]}, {"category": "license"}, False, id="none_of-match"), + pytest.param({"none_of": [_equals("category", "license")]}, {"category": "other"}, True, id="none_of-other"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {}, True, id="any_of-absent"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {"category": "other"}, True, id="any_of-false-or-unknown"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {"category": "other", "code": 1}, False, id="any_of-false"), + # exactly_one_of counts the members that are definitely true + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {}, False, id="exactly_one_of-absent"), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"category": "license"}, True, id="exactly_one_of-first"), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"code": 5}, True, id="exactly_one_of-second"), + pytest.param( + {"exactly_one_of": _LICENSE_OR_CODE}, {"category": "license", "code": 5}, False, id="exactly_one_of-both" + ), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"category": "other"}, False, id="exactly_one_of-none"), + pytest.param(_NO_TAGS_AND_LICENSE, {"tags": ["a"]}, False, id="absent-and-all_of-tags"), + pytest.param(_NO_TAGS_AND_LICENSE, {"category": "other"}, False, id="absent-and-all_of-other"), + pytest.param(_NO_TAGS_AND_LICENSE, {"category": "license"}, True, id="absent-and-all_of-match"), + pytest.param(_TWO_TAGS, {}, False, id="cardinality-absent"), + pytest.param(_TWO_TAGS, {"tags": ["a"]}, False, id="cardinality-too-few"), + pytest.param(_TWO_TAGS, {"tags": ["a", "b"]}, True, id="cardinality-enough"), + pytest.param(_CATEGORY_RANGE, {"category": "license"}, True, id="condition-range"), + pytest.param(_CATEGORY_RANGE, {"category": "other"}, False, id="condition-range-other"), + # a nested expression constrains the slots of the value's value, as induced for its range class + pytest.param(_REQUIRED_DETAILS, {"details": {"code": 2}}, True, id="nested"), + pytest.param(_REQUIRED_DETAILS, {"details": {"code": 1}}, False, id="nested-other"), + pytest.param(_REQUIRED_DETAILS, {"details": {}}, True, id="nested-absent"), + pytest.param(_REQUIRED_DETAILS, {}, False, id="nested-required"), + # under a negation, a nested expression takes its "definitely true" form + pytest.param(_NO_DETAILS_CODE, {"details": {}}, True, id="nested-in-none_of-absent"), + pytest.param(_NO_DETAILS_CODE, {"details": {"code": 3}}, False, id="nested-in-none_of-match"), + pytest.param(_NO_DETAILS_CODE, {"details": {"code": 1}}, True, id="nested-in-none_of-other"), + ], +) +@pytest.mark.parametrize("multivalued", [False, True]) +def test_range_expression_constrains_each_value( + schema: dict[str, Any], expression: dict[str, Any], value: dict[str, Any], valid: bool, multivalued: bool +) -> None: + """Both generators read a range expression alike, on a single value and on a list of values.""" + schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] = expression + schema["slots"]["license"]["multivalued"] = multivalued + assert verdicts(schema, {"license": [value] if multivalued else value}) == (valid, valid) + + +@pytest.mark.parametrize( + "values,valid", + [ + ([{"category": "license"}, {"category": "license"}], True), + ([{"category": "license"}, {"category": "other"}], False), + ([{"category": "license"}, {}], False), + ], +) +def test_range_expression_holds_for_every_value(schema: dict[str, Any], values: list[dict], valid: bool) -> None: + """Every value of a multivalued slot must satisfy the expression.""" + schema["slots"]["license"]["multivalued"] = True + assert verdicts(schema, {"license": values}) == (valid, valid) + + +@pytest.mark.parametrize( + "operator,value,valid", + [ + ("all_of", {}, True), + ("all_of", {"category": "license"}, True), + ("all_of", {"category": "other"}, False), + ("none_of", {}, True), + ("none_of", {"category": "license"}, False), + ("none_of", {"category": "other"}, True), + ], +) +def test_range_expression_in_class_expression( + schema: dict[str, Any], operator: str, value: dict[str, Any], valid: bool +) -> None: + """In a condition of a class-level expression, a range expression takes the form of the condition.""" + del schema["classes"]["Document"]["slot_usage"] + schema["classes"]["Document"][operator] = [ + {"slot_conditions": {"license": {"range_expression": _equals("category", "license")}}} + ] + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("value,valid", [({"category": "license"}, True), ({"category": "other"}, False)]) +def test_range_expression_on_union_branch(schema: dict[str, Any], value: dict[str, Any], valid: bool) -> None: + """A class alternative of a slot's ``any_of`` carries its own range expression.""" + expression = schema["classes"]["Document"].pop("slot_usage")["license"]["range_expression"] + schema["classes"]["Note"] = {"slots": ["text"]} + schema["slots"]["text"] = {"range": "string", "required": True} + schema["slots"]["license"] = { + "inlined": True, + "any_of": [{"range": "Resource", "range_expression": expression}, {"range": "Note"}], + } + # the RDF type of a value of a union is the alternative it is an instance of + assert verdicts(schema, {"license": {"@type": "Resource", **value}}) == (valid, valid) + assert verdicts(schema, {"license": {"@type": "Note", "text": "explanation"}}) == (True, True) + + +def test_union_branch_keeps_its_class(schema: dict[str, Any]) -> None: + """Satisfying the expression of a class alternative doesn't make a value an instance of that class.""" + expression = schema["classes"]["Document"].pop("slot_usage")["license"]["range_expression"] + schema["slots"]["license"] = {"inlined": True, "any_of": [{"range": "Resource", "range_expression": expression}]} + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] .') + assert not conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Details; ex:category "license"] .') + + +@pytest.mark.parametrize( + "value,valid", [({"category": "mit"}, True), ({"category": "eula"}, True), ({"category": "gpl"}, False)] +) +def test_range_expression_combining_enums(schema: dict[str, Any], value: dict[str, Any], valid: bool) -> None: + """A range expression on values that aren't class instances can combine enums.""" + del schema["classes"]["Document"]["slot_usage"] + schema["enums"] = { + "Open": {"permissible_values": {"mit": {}, "bsd": {}}}, + "Proprietary": {"permissible_values": {"eula": {}}}, + } + schema["slots"]["category"]["range_expression"] = {"any_of": [{"is_a": "Open"}, {"is_a": "Proprietary"}]} + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("in_class_expression", [False, True]) +@pytest.mark.parametrize("value,valid", [({"category": ["license"]}, True), ({"category": ["license", "x"]}, False)]) +def test_range_expression_uses_the_slots_of_the_range_class( + schema: dict[str, Any], value: dict[str, Any], valid: bool, in_class_expression: bool +) -> None: + """A condition constrains the slot as induced for the range class, whose ``slot_usage`` applies.""" + schema["classes"]["SpecialResource"] = {"is_a": "Resource", "slot_usage": {"category": {"multivalued": True}}} + schema["slots"]["license"]["range"] = "SpecialResource" + if in_class_expression: + del schema["classes"]["Document"]["slot_usage"] + condition = {"range_expression": _equals("category", "license")} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + else: + schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] = _equals("category", "license") + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("target", ["Document", "SpecialDocument"]) +@pytest.mark.parametrize("value,valid", [({"category": "license"}, True), ({"category": "other"}, False)]) +def test_range_expression_is_inherited(schema: dict[str, Any], target: str, value: dict[str, Any], valid: bool) -> None: + """A subclass keeps the range expression its parent places on a slot.""" + schema["classes"]["SpecialDocument"] = {"is_a": "Document"} + assert verdicts(schema, {"license": value}, target=target) == (valid, valid) + + +@pytest.mark.parametrize("category,valid", [("license", True), ("other", False)]) +def test_range_expression_on_values_inlined_as_dict(schema: dict[str, Any], category: str, valid: bool) -> None: + """The values of a slot inlined as a dict are the values of the dict, not the dict itself.""" + schema["classes"]["Resource"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + schema["slots"]["license"]["multivalued"] = True + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + instance = {"license": {"r1": {"category": "license"}, "r2": {"category": category}}} + assert Draft201909Validator(json_schema).is_valid(instance) is valid + data = 'ex:doc a ex:Document; ex:license ex:r1, ex:r2 . ex:r1 a ex:Resource; ex:category "license" . ' + assert conforms(schema, data + f'ex:r2 a ex:Resource; ex:category "{category}" .') is valid + + +@pytest.mark.parametrize( + "condition,licenses,valid", + [ + ({"minimum_cardinality": 1}, {"r1": {"category": "license"}}, True), + ({"minimum_cardinality": 1}, {}, False), + ({"maximum_cardinality": 1}, {"r1": {}, "r2": {}}, False), + ({"range_expression": _equals("category", "license")}, {"r1": {"category": "license"}}, True), + ({"range_expression": _equals("category", "license")}, {"r1": {"category": "other"}}, False), + ({"range": "SpecialResource"}, {"r1": {"code": 1}}, True), + ({"range": "SpecialResource"}, {"r1": {"category": "license"}}, False), + ], +) +def test_class_expression_on_values_inlined_as_dict( + schema: dict[str, Any], condition: dict[str, Any], licenses: dict[str, Any], valid: bool +) -> None: + """A condition of a class-level expression counts and constrains the values of a dict, as SHACL does.""" + del schema["classes"]["Document"]["slot_usage"] + schema["classes"]["Resource"]["slots"].insert(0, "id") + schema["classes"]["SpecialResource"] = {"is_a": "Resource", "slot_usage": {"code": {"required": True}}} + schema["slots"]["id"] = {"identifier": True, "range": "string"} + schema["slots"]["license"]["multivalued"] = True + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + assert Draft201909Validator(json_schema).is_valid({"license": licenses}) is valid + + +def test_range_expression_follows_the_alias(schema: dict[str, Any]) -> None: + """A condition names a slot of the value, whose alias is its JSON key and whose IRI is its RDF property.""" + schema["slots"]["category"]["alias"] = "kind" + validator = Draft201909Validator( + json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + ) + assert validator.is_valid({"license": {"kind": "license"}}) + assert not validator.is_valid({"license": {"kind": "other"}}) + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] .') + assert not conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + + +@pytest.mark.parametrize("type_statement,expected", [("a ex:Resource;", True), ("", False), ("a ex:Details;", False)]) +def test_range_expression_preserves_class_membership( + schema: dict[str, Any], type_statement: str, expected: bool +) -> None: + """Matching content cannot substitute for membership in the declared range.""" + assert conforms(schema, f'ex:doc a ex:Document; ex:license [{type_statement} ex:category "license"] .') is expected + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +@pytest.mark.parametrize("suffix", [None, "Shape"]) +@pytest.mark.parametrize("use_class_uri_names", [False, True]) +def test_range_expression_independent_of_shape_names( + schema: dict[str, Any], category: str, expected: bool, suffix: str | None, use_class_uri_names: bool +) -> None: + """The anonymous shape of a range expression works with either naming mode and a suffix.""" + data = f'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "{category}"] .' + assert conforms(schema, data, suffix=suffix, use_class_uri_names=use_class_uri_names) is expected + + +def test_range_expression_does_not_constrain_every_instance_of_the_range(schema: dict[str, Any]) -> None: + """Only the values of the constrained slot get the extra requirement.""" + assert conforms( + schema, + 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] . ' + 'ex:other a ex:Resource; ex:category "other" .', + ) + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_range_expression_on_referenced_values(schema: dict[str, Any], category: str, expected: bool) -> None: + """In RDF, a value that is a reference is constrained by its description in the data graph.""" + schema["classes"]["Resource"]["slots"].append("id") + schema["slots"]["id"] = {"identifier": True, "range": "uriorcurie"} + schema["slots"]["license"]["inlined"] = False + data = f'ex:doc a ex:Document; ex:license ex:resource . ex:resource a ex:Resource; ex:category "{category}" .' + assert conforms(schema, data) is expected + + +def test_range_expression_uses_the_condition_range(schema: dict[str, Any]) -> None: + """A range expression in a condition with a ``range`` resolves its conditions in that range class.""" + schema["classes"]["SpecialResource"] = {"is_a": "Resource", "attributes": {"grade": {"range": "integer"}}} + del schema["classes"]["Document"]["slot_usage"] + expression = {"slot_conditions": {"grade": {"minimum_value": 2}}} + condition = {"range": "SpecialResource", "range_expression": expression} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + data = "ex:doc a ex:Document; ex:license [a ex:Resource, ex:SpecialResource; ex:grade {}] ." + assert conforms(schema, data.format(2)) + assert not conforms(schema, data.format(1)) + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_cli_enforces_range_expression(schema: dict[str, Any], tmp_path: Path, category: str, expected: bool) -> None: + """The command line generates the same shapes, without an option.""" + path = tmp_path / "context.yaml" + path.write_text(yaml.safe_dump(schema)) + result = CliRunner().invoke(cli, [str(path), "--non-closed"]) + assert result.exit_code == 0, result.output + data = PREFIX + f'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "{category}"] .' + report = validate( + data_graph=data, data_graph_format="turtle", shacl_graph=result.output, shacl_graph_format="turtle" + ) + assert report[0] is expected + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_range_expression_on_imported_slots( + schema: dict[str, Any], tmp_path: Path, category: str, expected: bool +) -> None: + """With imported shapes excluded, a condition still resolves the IRI of an imported slot.""" + imported = { + "id": "https://example.org/imported", + "name": "imported", + "default_prefix": "imp", + "prefixes": {"imp": "https://example.org/imported/"}, + "classes": {"Resource": {"class_uri": "imp:Resource", "slots": ["category"]}}, + "slots": {"category": {"slot_uri": "imp:category", "range": "string"}}, + } + (tmp_path / "imported.yaml").write_text(yaml.safe_dump(imported)) + del schema["classes"]["Resource"] + del schema["slots"]["category"] + schema["imports"].append("imported") + path = tmp_path / "context.yaml" + path.write_text(yaml.safe_dump(schema)) + shapes = ShaclGenerator(str(path), closed=False, exclude_imports=True).serialize() + graph = Graph().parse(data=shapes, format="turtle") + assert (None, SH.targetClass, URIRef("https://example.org/imported/Resource")) not in graph + data = ( + f"{PREFIX} @prefix imp: . " + f'ex:doc a ex:Document; ex:license [a imp:Resource; imp:category "{category}"] .' + ) + report = validate(data_graph=data, data_graph_format="turtle", shacl_graph=graph, meta_shacl=True) + assert report[0] is expected + + +@pytest.mark.parametrize( + "condition,reason", + [ + ({"unit": {"ucum_code": "m"}}, "'unit' in the condition on slot 'category'"), + ({"equals_expression": "{code} * 2"}, "'equals_expression' in the condition on slot 'category'"), + ], +) +def test_untranslatable_range_expression_skipped_with_warning( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture, condition: dict[str, Any], reason: str +) -> None: + """A range expression SHACL cannot express is left out of the shapes of each class, with one warning.""" + expression = schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] + expression["slot_conditions"]["category"].update(condition) + schema["classes"]["SpecialDocument"] = {"is_a": "Document"} + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + assert [r.getMessage() for r in caplog.records] == [ + f"Slot 'license': range_expression is not translated to SHACL, because it uses {reason} " + "(in the shapes of 'Document', 'SpecialDocument')." + ] + + +def test_range_expression_with_conditions_on_values_that_are_not_class_instances( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """Values of a type or an enum have no slots for a condition to constrain.""" + del schema["classes"]["Document"]["slot_usage"] + schema["slots"]["category"]["range_expression"] = {"slot_conditions": {"code": {"required": True}}} + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert verdicts(schema, {"license": {"category": "other"}}) == (True, True) + assert [r.getMessage() for r in caplog.records] == [ + "Slot 'category': range_expression is not translated to SHACL, because it uses a condition on slot 'code' " + "of values that are not class instances (in the shapes of 'Resource')." + ] + + +def test_untranslatable_range_expression_in_class_expression_skips_it( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A class-level expression whose range expression cannot be translated is skipped as a whole.""" + del schema["classes"]["Document"]["slot_usage"] + expression = {"slot_conditions": {"category": {"equals_string": "license", "unit": {"ucum_code": "m"}}}} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": {"range_expression": expression}}}] + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + assert [r.getMessage() for r in caplog.records] == [ + "Class 'Document': all_of is not translated to SHACL, because it uses " + "'unit' in the condition on slot 'category'." + ] diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index f367de97e7..29ca466421 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -1,9 +1,13 @@ +import json +import logging +import re from collections import Counter from typing import Any import pytest import rdflib -from rdflib import RDF, RDFS, SH, Literal, URIRef +import yaml +from rdflib import RDF, RDFS, SH, XSD, Literal, URIRef from rdflib.collection import Collection from linkml.generators.shacl.shacl_data_type import ShaclDataType @@ -2108,7 +2112,7 @@ def test_rule_deactivated_skipped(): def test_rule_unsupported_pattern_skipped(): - """Unrecognised rule patterns are silently skipped (no sh:sparql emitted).""" + """Unrecognised rule patterns are skipped (no sh:sparql emitted).""" g = _parse_shacl(_UNSUPPORTED_RULE_SCHEMA_YAML) shape = URIRef("https://example.org/unsupported-test/TestClass") @@ -2418,9 +2422,11 @@ def test_rule_sparql_syntax_valid(): prepareQuery(query_text) -# =========================================================================== +# ==================================================================== + # Exclusive-value pattern tests (SHACL §5 SPARQL constraints) -# =========================================================================== +# ==================================================================== + # # The "exclusive value" pattern translates a LinkML rule where: # - preconditions: slot X has equals_string (a specific enum value name) @@ -2437,7 +2443,8 @@ def test_rule_sparql_syntax_valid(): # - W3C SHACL §5 # - W3C SHACL §5.3.1 # - ISO 34503:2023, 9.3.6 (motivating use case: EdgeNone exclusivity) -# =========================================================================== +# ==================================================================== + _EXCLUSIVE_VALUE_SCHEMA_YAML = """ id: https://example.org/exclusive-value @@ -2585,13 +2592,14 @@ def test_exclusive_value_sparql_uses_enum_iri(): def test_exclusive_value_max_card_1_sparql_structure(): - """For maximum_cardinality: 1, SPARQL uses FILTER(?other != ). + """For maximum_cardinality: 1, SPARQL reports each ?other value coexisting with . The query pattern for N=1 is: - SELECT $this WHERE { - $this . + SELECT DISTINCT $this ( AS ?path) (?other AS ?value) WHERE { + $this ?exclusive . $this ?other . - FILTER (?other != ) + FILTER ( COALESCE( ?exclusive = , false ) && + !COALESCE( ?other = , false ) ) } This is more efficient than the COUNT-based approach for the common @@ -2616,13 +2624,14 @@ def test_exclusive_value_max_card_gt1_sparql_structure(): """For maximum_cardinality > 1, SPARQL uses COUNT-based subquery. The query pattern for N>1 is: - SELECT $this WHERE { - $this . + SELECT DISTINCT $this ( AS ?path) WHERE { + $this ?exclusive . + FILTER ( COALESCE( ?exclusive = , false ) ) { - SELECT $this (COUNT(?val) AS ?count) - WHERE { $this ?val . } + SELECT $this + WHERE { $this ?item . } GROUP BY $this - HAVING (?count > N) + HAVING ( COUNT(?item) > N ) } } """ @@ -2833,3 +2842,4344 @@ def test_shacl_modular_schema_with_reused_attribute_name(tmp_path) -> None: graph.parse(data=ShaclGenerator(str(domain)).serialize(), format="turtle") shapes = set(graph.subjects(RDF.type, SH.NodeShape)) assert URIRef("https://example.org/domain/Pedido") in shapes + + +# ==================================================================== + +# Presence-implies-value pattern tests (enum guard) +# ==================================================================== + +# +# The "presence implies value" pattern generalises the boolean guard to +# enum-valued targets. It translates a LinkML rule where: +# - preconditions: a guard slot has value_presence: PRESENT +# - postconditions: a target slot has equals_string (single required value) +# or equals_string_in (a set of acceptable values) +# +# Semantics: "If the guard slot is present, the target slot must be present +# and hold one of the allowed values", e.g. "if a document has a signature, +# its status must be Published". +# +# References: +# - W3C SHACL §5 +# - W3C SHACL §5.3.1 +# - W3C SHACL §5.3.2 +# ==================================================================== + + +_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML = """ +id: https://example.org/presence-implies-value +name: presence_implies_value_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/presence-implies-value/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +enums: + StatusEnum: + permissible_values: + Draft: + meaning: ex:Draft + Reviewed: + meaning: ex:Reviewed + Approved: + meaning: ex:Approved + Published: + meaning: ex:Published + + ModeEnum: + permissible_values: + Auto: + description: Automatic mode (no meaning IRI). + Manual: + description: Manual mode (no meaning IRI). + +slots: + status: + range: StatusEnum + slot_uri: ex:status + signature: + range: string + slot_uri: ex:signature + review_score: + range: float + slot_uri: ex:review_score + mode: + range: ModeEnum + slot_uri: ex:mode + manual_value: + range: decimal + slot_uri: ex:manual_value + +classes: + Document: + class_uri: ex:Document + slots: + - status + - signature + - review_score + rules: + - description: If a document has a signature, its status must be Published. + preconditions: + slot_conditions: + signature: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string: "Published" + - description: If a document has a review score, its status must be Reviewed or Approved. + preconditions: + slot_conditions: + review_score: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string_in: + - Reviewed + - Approved + + Device: + class_uri: ex:Device + slots: + - mode + - manual_value + rules: + - description: If manual_value is provided, mode must be Manual (literal fallback). + preconditions: + slot_conditions: + manual_value: + value_presence: PRESENT + postconditions: + slot_conditions: + mode: + equals_string: "Manual" +""" + +EX_PIV = rdflib.Namespace("https://example.org/presence-implies-value/") + + +def _rule_results(schema: str, data: str | rdflib.Graph) -> tuple[bool, list[tuple]]: + """Validate *data* against the shapes generated for *schema*. + + *data* is Turtle or a graph. Returns whether the data conforms overall, + and the ``(focus node, result path, value)`` of each result that a rule + constraint (``sh:sparql``) reports, so that a property-shape result + (datatype, cardinality, ...) can neither mask nor stand in for what a rule + decides. + """ + import pyshacl + + shapes = rdflib.Graph().parse(data=ShaclGenerator(schema, mergeimports=False).serialize(), format="turtle") + data_graph = data if isinstance(data, rdflib.Graph) else rdflib.Graph().parse(data=data, format="turtle") + conforms, report, _ = pyshacl.validate(data_graph, shacl_graph=shapes, advanced=True) + results = [ + (report.value(result, SH.focusNode), report.value(result, SH.resultPath), report.value(result, SH.value)) + for result in report.subjects(SH.sourceConstraintComponent, SH.SPARQLConstraintComponent) + ] + return conforms, results + + +def _validate_rules(schema: str, data: str | rdflib.Graph) -> tuple[bool, set]: + """Whether *data* conforms to the shapes of *schema*, and the focus nodes its rule constraints report.""" + conforms, results = _rule_results(schema, data) + return conforms, {focus_node for focus_node, _, _ in results} + + +def _sparql_queries(g: rdflib.Graph, shape: URIRef) -> list[str]: + """The ``sh:select`` query of every ``sh:sparql`` constraint on *shape*.""" + return [str(query) for node in g.objects(shape, SH.sparql) for query in g.objects(node, SH.select)] + + +def test_presence_implies_value_generates_sparql(): + """Presence-implies-value rules produce sh:sparql constraints on the NodeShape.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + shape = EX_PIV.Document + sparql_nodes = list(g.objects(shape, SH.sparql)) + assert len(sparql_nodes) == 2, f"Expected 2 sh:sparql constraints, got {len(sparql_nodes)}" + + for node in sparql_nodes: + assert (node, RDF.type, SH.SPARQLConstraint) in g + selects = list(g.objects(node, SH.select)) + assert len(selects) == 1, "Each constraint must have exactly one sh:select" + query = str(selects[0]) + assert "$this" in query, "SPARQL must use $this pre-bound variable" + assert "?value = " in query, "presence-implies-value SPARQL must compare the target by value" + assert "FILTER" in query, "SPARQL must have a FILTER clause" + + +def test_presence_implies_value_single_uses_enum_iri(): + """A single equals_string target resolves to the enum meaning IRI.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + signature_query = [q for q in _sparql_queries(g, EX_PIV.Document) if str(EX_PIV.signature) in q] + assert len(signature_query) == 1, "Expected exactly one signature rule" + query = signature_query[0] + + assert str(EX_PIV.status) in query + assert f"<{EX_PIV.Published}>" in query, f"Expected Published IRI, got:\n{query}" + + +def test_presence_implies_value_set_uses_all_iris(): + """equals_string_in resolves every allowed value to its enum meaning IRI.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + review_query = [q for q in _sparql_queries(g, EX_PIV.Document) if str(EX_PIV.review_score) in q] + assert len(review_query) == 1, "Expected exactly one review_score rule" + query = review_query[0] + + assert f"<{EX_PIV.Reviewed}>" in query, f"Expected Reviewed IRI, got:\n{query}" + assert f"<{EX_PIV.Approved}>" in query, f"Expected Approved IRI, got:\n{query}" + + +def test_presence_implies_value_no_meaning_falls_back_to_literal(): + """When the target enum value lacks a meaning IRI, it is compared as a literal.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + queries = _sparql_queries(g, EX_PIV.Device) + assert len(queries) == 1 + query = queries[0] + assert '"Manual"' in query, f"No-meaning enum should use literal '\"Manual\"', got:\n{query}" + assert f"<{EX_PIV}Manual>" not in query, "Should not emit as IRI when meaning is absent" + + +def test_presence_implies_value_message_from_description(): + """Rule description is emitted as sh:message on the SPARQLConstraint.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + sparql_nodes = list(g.objects(EX_PIV.Document, SH.sparql)) + messages = [str(m) for node in sparql_nodes for m in g.objects(node, SH.message)] + + assert any("status must be Published" in m for m in messages), f"Expected message about Published, got: {messages}" + + +def test_presence_implies_value_sparql_syntax_valid(): + """Generated SPARQL for presence-implies-value rules must be syntactically valid.""" + from rdflib.plugins.sparql import prepareQuery + + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + for shape in (EX_PIV.Document, EX_PIV.Device): + for query in _sparql_queries(g, shape): + prepareQuery(query) + + +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param('ex:x a ex:Document ; ex:signature "s" ; ex:status ex:Published .', False, id="single-ok"), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "4.5"^^xsd:float ; ex:status ex:Reviewed .', False, id="set-ok" + ), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "3.0"^^xsd:float ; ex:status ex:Approved .', + False, + id="set-other-member-ok", + ), + pytest.param("ex:x a ex:Document ; ex:status ex:Draft .", False, id="unguarded"), + pytest.param('ex:x a ex:Document ; ex:signature "s" ; ex:status ex:Draft .', True, id="wrong-value"), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "4.5"^^xsd:float ; ex:status ex:Published .', + True, + id="not-in-set", + ), + pytest.param('ex:x a ex:Document ; ex:signature "s" .', True, id="target-absent"), + pytest.param('ex:x a ex:Device ; ex:manual_value 1.5 ; ex:mode "Manual" .', False, id="literal-ok"), + pytest.param('ex:x a ex:Device ; ex:manual_value 1.5 ; ex:mode "Auto" .', True, id="literal-wrong"), + ], +) +def test_presence_implies_value_pyshacl_end_to_end(instance: str, violates: bool): + """End-to-end: pyshacl passes conforming instances and flags violations.""" + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + instance + ) + conforms, focus_nodes = _validate_rules(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML, data) + assert focus_nodes == ({EX_PIV.x} if violates else set()) + assert conforms is not violates + + +@pytest.mark.parametrize( + "instance,value", + [ + pytest.param('ex:signature "s" ; ex:status ex:Draft', EX_PIV.Draft, id="wrong-value"), + pytest.param('ex:signature "s"', EX_PIV.x, id="target-absent"), + ], +) +def test_presence_implies_value_result_names_path_and_value(instance, value): + """A result names the target property (``sh:resultPath``) and the + offending value (``sh:value``); when the target is absent, SHACL §5.3.2 + falls back to the focus node as the value.""" + data = f"@prefix ex: .\nex:x a ex:Document ; {instance} ." + _, results = _rule_results(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML, data) + assert results == [(EX_PIV.x, EX_PIV.status, value)] + + +# ==================================================================== + +# equals_string / equals_string_in compare strings +# ==================================================================== + +# +# The metamodel defines both operators for slots of range string: "the slot +# must have range string and the value of the slot must equal the specified +# value". Enums, whose values are strings in instance data, and types with +# datatype xsd:string are treated alike. A rule applying them to a slot of +# any other range is skipped with a warning: its RDF values are typed literals +# ("3"^^xsd:integer, false) or IRIs that equal no string, so a translated +# constraint would report conforming data or never fire. On a string-valued +# slot the value is compared with the SPARQL "=" operator, which matches the +# RDF 1.1-identical plain and xsd:string literal forms alike, and an +# incomparable value counts as unequal rather than as an error. +# +# References: +# - SPARQL 1.1 §17.4.1.3 +# - SPARQL 1.1 §17.4.1.9 +# - RDF 1.1 Concepts §3.3 +# ==================================================================== + + +_RULE_TARGET_RANGE_SCHEMA_YAML = """ +id: https://example.org/rule-target-range +name: rule_target_range +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/rule-target-range/ + xsd: http://www.w3.org/2001/XMLSchema# +imports: + - linkml:types +default_prefix: ex +default_range: string +types: + label_string: + typeof: string + token_string: + typeof: string + uri: xsd:token + flag_boolean: + typeof: boolean +enums: + Code: + permissible_values: + alpha: + beta: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Answer: + permissible_values: + "true": + meaning: ex:Yes + "false": + meaning: ex:No + Mixed: + permissible_values: + A: + meaning: ex:A + B: +classes: + Item: + class_uri: ex:Item + attributes: + id: + identifier: true + range: uriorcurie + Thing: + class_uri: ex:Thing + attributes: + guard: + multivalued: {guard_multivalued} + target: + range: {range} + multivalued: {multivalued} + rules: + - open_world: {open_world} + preconditions: + slot_conditions: + guard: {guard_condition} + postconditions: + slot_conditions: + target: {target_condition} + Sub: + is_a: Thing + class_uri: ex:Sub +""" + +EX_RTR = rdflib.Namespace("https://example.org/rule-target-range/") + +_RTR_PREFIXES = ( + "@prefix ex: .\n@prefix xsd: .\n" +) + + +def _rule_target_schema( + target_range: str, + value: str | list[str], + multivalued: bool = False, + *, + operator: str = "equals_string", + open_world: bool = False, + guard_multivalued: bool = False, + guard_extra: dict | None = None, + target_extra: dict | None = None, +) -> str: + """The rule-target schema: if ``guard`` is present, ``target`` (of *target_range*) must satisfy *operator*. + + *value* is the operand of *operator* (a list for ``equals_string_in``); + *guard_extra* and *target_extra* add operators to the two conditions. + ``Sub`` inherits the rule from ``Thing``. + """ + return _RULE_TARGET_RANGE_SCHEMA_YAML.format( + range=target_range, + multivalued=str(multivalued).lower(), + guard_multivalued=str(guard_multivalued).lower(), + open_world=str(open_world).lower(), + guard_condition=json.dumps({"value_presence": "PRESENT", **(guard_extra or {})}), + target_condition=json.dumps({operator: value, **(target_extra or {})}), + ) + + +@pytest.mark.parametrize( + "target_range,operator,value,conforming_term", + [ + pytest.param("integer", "equals_string", "3", "3", id="integer"), + pytest.param("integer", "equals_string_in", ["3", "4"], "3", id="integer-equals-string-in"), + pytest.param("float", "equals_string", "1.5", '"1.5"^^xsd:float', id="float"), + pytest.param("decimal", "equals_string", "1.5", "1.5", id="decimal"), + pytest.param("date", "equals_string", "2024-01-01", '"2024-01-01"^^xsd:date', id="date"), + pytest.param("uriorcurie", "equals_string", "ex:a", "ex:a", id="uriorcurie"), + pytest.param("token_string", "equals_string", "a", '"a"^^xsd:token', id="string-derived-xsd-token"), + pytest.param("Item", "equals_string", "ex:i1", "ex:i1 . ex:i1 a ex:Item", id="class"), + ], +) +def test_rule_equals_string_on_non_string_range_skipped(caplog, target_range, operator, value, conforming_term): + """equals_string on a slot that does not hold strings is skipped with a warning. + + The data holds a typed literal or an IRI, which equals no string: comparing + it with one would report every conforming instance. The conforming instance + must therefore pass, and the skip must be reported. + """ + schema = _rule_target_schema(target_range, value, operator=operator) + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + + assert _sparql_queries(g, EX_RTR.Thing) == [] + assert _sparql_queries(g, EX_RTR.Sub) == [] + assert any( + f"slot 'target', whose range '{target_range}' is neither an enum nor a type with datatype xsd:string or " + "xsd:boolean" in rec.message + for rec in caplog.records + ), caplog.text + conforms, focus_nodes = _validate_rules( + schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target {conforming_term} .' + ) + assert conforms and focus_nodes == set() + + +_STRING_TARGETS = [ + pytest.param("string", "x", '"x"', '"y"', id="string"), + pytest.param("label_string", "x", '"x"', '"y"', id="string-derived"), + pytest.param("curie", "ex:a", '"ex:a"', '"ex:b"', id="curie"), + pytest.param("Code", "alpha", '"alpha"', '"beta"', id="enum-literal"), + pytest.param("Color", "Red", "ex:Red", "ex:Blue", id="enum-meaning"), + pytest.param("Answer", "true", "ex:Yes", "ex:No", id="enum-meaning-named-true"), +] + + +@pytest.mark.parametrize("target_range,value,allowed,other", _STRING_TARGETS) +@pytest.mark.parametrize( + "body,violates", + [ + pytest.param('ex:guard "g" ; ex:target {allowed}', False, id="allowed"), + pytest.param('ex:guard "g" ; ex:target {allowed_xsd_string}', False, id="allowed-xsd-string"), + pytest.param('ex:guard "g" ; ex:target {other}', True, id="other"), + pytest.param('ex:guard "g" ; ex:target {allowed}, {other}', True, id="allowed-and-other"), + pytest.param('ex:guard "g"', True, id="target-absent"), + pytest.param("ex:target {other}", False, id="guard-absent"), + ], +) +@pytest.mark.parametrize("multivalued", [False, True]) +def test_rule_equals_string_on_string_range_compares_values( + target_range, value, allowed, other, body, violates, multivalued +): + """On a string-valued target, equals_string compares the value the range holds. + + A string type holds plain literals, an enum its meaning IRI or a plain + literal; the ``xsd:string``-typed form of a plain literal is the same RDF + 1.1 term and must also satisfy the rule. An enum value named ``true`` + with a meaning is an IRI like any other, not a boolean. + """ + allowed_xsd_string = f"{allowed}^^xsd:string" if allowed.startswith('"') else allowed + schema = _rule_target_schema(target_range, value, multivalued) + data = body.format(allowed=allowed, allowed_xsd_string=allowed_xsd_string, other=other) + + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + _, focus_nodes = _validate_rules(schema, f"{_RTR_PREFIXES}ex:x a ex:Thing ; {data} .") + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +_RULE_INSTANCES = [ + pytest.param({"guard": "g", "target": "{value}"}, True, True, id="allowed"), + pytest.param({"guard": "g", "target": "{other}"}, False, False, id="other"), + pytest.param({"guard": "g"}, False, True, id="target-absent"), + pytest.param({"target": "{other}"}, True, True, id="guard-absent"), +] +"""JSON instances of the rule-target schema, with their validity under closed- and open-world rules.""" + + +def _json_schema_and_shacl_verdicts( + schema: str, target_class: str, obj: dict, coerce_xsd_string: bool = False +) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL rules generated for *schema* accept *obj*. + + *obj* is validated as is by the generated JSON Schema, and as RDF loaded + through the generated JSON-LD context by the generated SHACL shapes (rule + constraints only). With *coerce_xsd_string* the context types the + ``target`` slot ``xsd:string``, as JSON-LD 1.1 type coercion permits, + which yields the RDF 1.1-identical typed form of a plain literal. + """ + import jsonschema + + from linkml.generators.jsonldcontextgen import ContextGenerator + from linkml.generators.jsonschemagen import JsonSchemaGenerator + + json_schema = json.loads(JsonSchemaGenerator(schema, top_class=target_class).serialize()) + json_schema_valid = jsonschema.Draft7Validator(json_schema).is_valid(obj) + + context = json.loads(ContextGenerator(schema).serialize())["@context"] + if coerce_xsd_string and context["target"].get("@type") is None: + context["target"]["@type"] = "xsd:string" + document = {"@context": context, "@type": target_class, **obj} + _, focus_nodes = _validate_rules(schema, rdflib.Graph().parse(data=json.dumps(document), format="json-ld")) + return json_schema_valid, focus_nodes == set() + + +@pytest.mark.parametrize( + "target_range,value,other", + [ + pytest.param("string", "x", "y", id="string"), + pytest.param("curie", "ex:a", "ex:b", id="curie"), + pytest.param("Code", "alpha", "beta", id="enum-literal"), + pytest.param("Color", "Red", "Blue", id="enum-meaning"), + ], +) +@pytest.mark.parametrize("instance,valid,_valid_open_world", _RULE_INSTANCES) +@pytest.mark.parametrize("coerce_xsd_string", [False, True]) +def test_rule_equals_string_agrees_with_json_schema( + target_range, value, other, instance, valid, _valid_open_world, coerce_xsd_string +): + """The SHACL rule and the JSON Schema rule decide the same instance alike. + + One JSON object is checked by both generated artifacts (see + :func:`_json_schema_and_shacl_verdicts`), with and without ``xsd:string`` + coercion of the target, which the test adds to the generated context. An + enum whose values all have a meaning in one namespace is mapped to those + IRIs by the context and stays untyped. + """ + obj = {key: text.format(value=value, other=other) for key, text in instance.items()} + verdicts = _json_schema_and_shacl_verdicts( + _rule_target_schema(target_range, value), "Thing", obj, coerce_xsd_string + ) + assert verdicts == (valid, valid) + + +@pytest.mark.parametrize( + "target_range,value,other", + [ + pytest.param("string", "x", "y", id="string"), + pytest.param("Color", "Red", "Blue", id="enum-meaning"), + ], +) +@pytest.mark.parametrize("instance,valid_closed_world,valid_open_world", _RULE_INSTANCES) +@pytest.mark.parametrize("target_class", ["Thing", "Sub"]) +@pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize( + "guard_extra", [None, {"required": True}], ids=["presence-implies-value", "composed-equivalent"] +) +def test_rule_inheritance_and_open_world_agree_with_json_schema( + target_range, value, other, instance, valid_closed_world, valid_open_world, target_class, open_world, guard_extra +): + """A rule binds the instances of subclasses of its class, and ``open_world`` + lets the postcondition be omitted, in SHACL as in JSON Schema. + + The metamodel defines ``open_world`` as "the postconditions may be omitted + in instance data", so an absent target satisfies an open-world rule. A + guard that also states ``required: true`` means the same and is composed + rather than matched by the named pattern, with the same verdicts. + """ + obj = {key: text.format(value=value, other=other) for key, text in instance.items()} + schema = _rule_target_schema(target_range, value, open_world=open_world, guard_extra=guard_extra) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR[target_class]) + assert ("?pre0" in query) == (guard_extra is not None), query # the composed form filters on ?pre0 + valid = valid_open_world if open_world else valid_closed_world + assert _json_schema_and_shacl_verdicts(schema, target_class, obj) == (valid, valid) + + +@pytest.mark.parametrize("flag_range", ["boolean", "flag_boolean"]) +@pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize("required", ["true", "1", "false", "0"]) +@pytest.mark.parametrize( + "flag,datatype", + [ + pytest.param("true", XSD.boolean, id="true"), + pytest.param("1", XSD.boolean, id="lexical-1"), + pytest.param("false", XSD.boolean, id="false"), + pytest.param("0", XSD.boolean, id="lexical-0"), + pytest.param("true", None, id="string-true"), + pytest.param(None, None, id="absent"), + ], +) +def test_rule_boolean_target_compared_by_value(flag_range, open_world, required, flag, datatype): + """equals_string on an xsd:boolean-typed slot (the boolean guard, for + "true") requires the boolean its value denotes in the lexical space of + xsd:boolean (XML Schema 1.1 Part 2 §3.3.2.2), whichever type the slot's + range derives it from. The flag is compared by value, so every lexical + form of a boolean satisfies the rule and a string "true" does not; an + open-world rule lets the flag be omitted. + + The data is built with unnormalised literals: rdflib would otherwise + rewrite ``"1"^^xsd:boolean`` to ``true`` while parsing, and a lexical + comparison would pass as well. + """ + schema = _rule_target_schema(flag_range, required, open_world=open_world) + required_value = required in ("true", "1") + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert len(queries) == 1 + assert f"?value = {str(required_value).lower()}" in queries[0], queries[0] + + data = rdflib.Graph() + data.add((EX_RTR.x, RDF.type, EX_RTR.Thing)) + data.add((EX_RTR.x, EX_RTR.guard, Literal("g"))) + if flag is not None: + data.add((EX_RTR.x, EX_RTR.target, Literal(flag, datatype=datatype, normalize=False))) + if flag is None: + violates = not open_world + else: + violates = datatype is None or (flag in ("true", "1")) != required_value + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +@pytest.mark.parametrize("value", ["yes", "True", "TRUE", " true", ""]) +def test_rule_boolean_target_non_lexical_value_skipped(caplog, value): + """A value outside the lexical space of xsd:boolean on a boolean slot equals + no boolean, so the rule is skipped with a warning. The lexical space is + exactly {true, false, 1, 0}: it is case-sensitive and has no whitespace.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_rule_target_schema("boolean", value)) + assert _sparql_queries(g, EX_RTR.Thing) == [] + assert any("not all xsd:boolean lexical forms" in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "target,violates", + [ + pytest.param("ex:A", False, id="meaning-iri"), + pytest.param('"B"', False, id="literal"), + pytest.param('"B"^^xsd:string', False, id="literal-xsd-string"), + pytest.param('"A"', True, id="literal-of-value-with-meaning"), + pytest.param("ex:B", True, id="iri-of-value-without-meaning"), + pytest.param('"C"', True, id="other"), + ], +) +def test_rule_equals_string_in_mixes_meaning_iris_and_literals(target, violates): + """``equals_string_in`` over permissible values with and without a meaning + compares each as the term it is rendered as: its meaning IRI, or a literal.""" + schema = _rule_target_schema("Mixed", ["A", "B"], operator="equals_string_in") + _, focus_nodes = _validate_rules(schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target {target} .') + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +@pytest.mark.parametrize( + "target,violates", + [pytest.param('"x"', False, id="allowed"), pytest.param('"y"', True, id="other")], +) +def test_rule_multivalued_guard(target, violates): + """Several guard values trigger the rule once per focus node, like one.""" + schema = _rule_target_schema("string", "x", guard_multivalued=True) + data = f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g1", "g2", "g3" ; ex:target {target} .' + _, results = _rule_results(schema, data) + assert results == ([(EX_RTR.x, EX_RTR.target, rdflib.Literal("y"))] if violates else []) + + +def test_rule_query_yields_one_solution_per_focus_node(): + """Each solution of a SPARQL-based constraint is a validation result + (SHACL §5.3.2), so several guard values must not multiply the solutions. + pyshacl merges identical results; the query itself must not rely on it.""" + schema = _rule_target_schema("string", "x", guard_multivalued=True) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + data = rdflib.Graph().parse( + data=f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g1", "g2", "g3" ; ex:target "y" .', format="turtle" + ) + assert len(list(data.query(query, initBindings={"this": EX_RTR.x}))) == 1 + + +@pytest.mark.parametrize("dawg_literal_collation", [False, True]) +def test_rule_incomparable_value_is_a_violation(monkeypatch, dawg_literal_collation): + """A value no allowed value can be compared with equals none of them. + + SPARQL ``=`` raises a type error for literals it cannot compare + (RDFterm-equal, SPARQL 1.1 §17.4.1.7), and a FILTER error drops the row, so + a spec-conformant engine would miss the violation unless the comparison + turns the error into false. ``rdflib.DAWG_LITERAL_COLLATION`` switches + rdflib from its lenient comparison to that spec-conformant one. + """ + monkeypatch.setattr(rdflib, "DAWG_LITERAL_COLLATION", dawg_literal_collation) + schema = _rule_target_schema("string", "x") + data = f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target "x"^^ex:custom .' + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == {EX_RTR.x} + + exclusive = _exclusive_schema("Tag", "alpha", 1) + data = ( + '@prefix ex: .\nex:x a ex:Thing ; ex:tags "alpha", "beta"^^ex:custom .' + ) + _, focus_nodes = _validate_rules(exclusive, data) + assert focus_nodes == {EX_EXR.x} + + +def test_rule_value_not_a_permissible_value_warns(caplog): + """An equals_string value that is not a permissible value of the enum is + reported: no valid value of the slot can equal it, so the rule rejects + every instance it applies to.""" + schema = _rule_target_schema("Color", "Purple") + with caplog.at_level(logging.WARNING): + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + + assert len(queries) == 1 + assert any("'Purple', which is not a permissible value of enum 'Color'" in rec.message for rec in caplog.records) + _, focus_nodes = _validate_rules(schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target ex:Red .') + assert focus_nodes == {EX_RTR.x} + + +@pytest.mark.parametrize( + "value", + [ + pytest.param('a"b', id="quote"), + pytest.param("a\\b", id="backslash"), + pytest.param("line\nbreak", id="newline"), + pytest.param("carriage\rreturn", id="carriage-return"), + pytest.param("tab\there", id="tab"), + pytest.param("a\\u0022b", id="backslash-u"), + pytest.param("a\\U00000022b", id="backslash-capital-u"), + pytest.param("check ✓", id="non-ascii"), + pytest.param("", id="empty"), + ], +) +def test_rule_string_value_round_trips(value): + """An equals_string value of any content is matched exactly: the query + parses, the value itself satisfies the rule, and any other value does not. + + Codepoint escapes are replaced before a SPARQL query is parsed (SPARQL 1.1 + §19.2), so a backslash followed by ``u`` in the value must not reach the + query as an escape sequence. + """ + from rdflib.plugins.sparql import prepareQuery + + schema = _rule_target_schema("string", value) + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert len(queries) == 1 + prepareQuery(queries[0]) + + for target, violates in ((value, False), (value + "!", True)): + data = rdflib.Graph() + data.add((EX_RTR.x, RDF.type, EX_RTR.Thing)) + data.add((EX_RTR.x, EX_RTR.guard, Literal("g"))) + data.add((EX_RTR.x, EX_RTR.target, Literal(target))) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_RTR.x} if violates else set()), repr(target) + + +_EXCLUSIVE_RANGE_SCHEMA_YAML = """ +id: https://example.org/exclusive-range +name: exclusive_range +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/exclusive-range/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Tag: + permissible_values: + alpha: + beta: + gamma: +classes: + Thing: + class_uri: ex:Thing + attributes: + tags: + range: {range} + multivalued: true + rules: + - preconditions: + slot_conditions: + tags: {precondition} + postconditions: + slot_conditions: + tags: + maximum_cardinality: {max_card} +""" + +EX_EXR = rdflib.Namespace("https://example.org/exclusive-range/") + + +def _exclusive_schema(tag_range: str, value: str, max_card: int, *, has_member: bool = True) -> str: + """The exclusive-value schema: if *value* is one of the ``tags``, there are at most *max_card* of them. + + The precondition is ``has_member: {equals_string: value}``, or with + ``has_member=False`` the bare ``equals_string: value``. + """ + precondition = {"has_member": {"equals_string": value}} if has_member else {"equals_string": value} + return _EXCLUSIVE_RANGE_SCHEMA_YAML.format( + range=tag_range, precondition=json.dumps(precondition), max_card=max_card + ) + + +@pytest.mark.parametrize("has_member", [True, False]) +@pytest.mark.parametrize( + "max_card,values,violates", + [ + pytest.param(1, '"alpha", "beta"', True, id="max1-plain"), + pytest.param(1, '"alpha"^^xsd:string, "beta"^^xsd:string', True, id="max1-xsd-string"), + pytest.param(1, '"alpha"', False, id="max1-alone"), + pytest.param(1, '"beta", "gamma"', False, id="max1-absent"), + pytest.param(2, '"alpha", "beta", "gamma"', True, id="max2-plain"), + pytest.param(2, '"alpha"^^xsd:string, "beta", "gamma"', True, id="max2-xsd-string"), + pytest.param(2, '"alpha", "beta"', False, id="max2-within"), + ], +) +def test_exclusive_value_matches_value_not_term(has_member, max_card, values, violates): + """The exclusive value is matched by value, so its RDF 1.1-identical + ``xsd:string``-typed form triggers the rule as the plain literal does, and + a ``maximum_cardinality`` above 1 is enforced. The precondition + ``has_member: {equals_string: V}`` and the bare ``equals_string: V`` are + translated alike.""" + schema = _exclusive_schema("Tag", "alpha", max_card, has_member=has_member) + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + f"ex:x a ex:Thing ; ex:tags {values} ." + ) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_EXR.x} if violates else set()) + + +@pytest.mark.parametrize("max_card", [1, 2]) +def test_exclusive_value_result_names_path_and_value(max_card): + """A result names the slot. For ``maximum_cardinality: 1`` its value is + each value coexisting with the exclusive one; otherwise no value is + projected and SHACL §5.3.2 falls back to the focus node.""" + data = '@prefix ex: .\nex:x a ex:Thing ; ex:tags "alpha", "beta", "gamma" .' + _, results = _rule_results(_exclusive_schema("Tag", "alpha", max_card), data) + if max_card == 1: + assert sorted(results) == sorted( + [(EX_EXR.x, EX_EXR.tags, Literal("beta")), (EX_EXR.x, EX_EXR.tags, Literal("gamma"))] + ) + else: + assert results == [(EX_EXR.x, EX_EXR.tags, EX_EXR.x)] + + +@pytest.mark.parametrize("max_card", [1, 2]) +def test_exclusive_value_query_yields_distinct_solutions(max_card): + """The exclusive value in both RDF 1.1-identical forms binds twice; each + solution is a validation result (SHACL §5.3.2), so the query must not + multiply them.""" + (query,) = _sparql_queries(_parse_shacl(_exclusive_schema("Tag", "alpha", max_card)), EX_EXR.Thing) + data = rdflib.Graph().parse( + data="@prefix ex: .\n" + "@prefix xsd: .\n" + 'ex:x a ex:Thing ; ex:tags "alpha", "alpha"^^xsd:string, "beta" .', + format="turtle", + ) + assert len(list(data.query(query, initBindings={"this": EX_EXR.x}))) == 1 + + +def test_exclusive_value_bare_equals_string_warns(caplog): + """A bare equals_string precondition on a multivalued slot is translated as + "one of the values equals", with a warning: the specification applies a + slot constraint to all members of a collection, and has_member states the + intended reading explicitly.""" + with caplog.at_level(logging.WARNING): + assert _sparql_queries(_parse_shacl(_exclusive_schema("Tag", "alpha", 1, has_member=False)), EX_EXR.Thing) + assert any("has_member" in rec.message and "all members" in rec.message for rec in caplog.records), caplog.text + caplog.clear() + with caplog.at_level(logging.WARNING): + _parse_shacl(_exclusive_schema("Tag", "alpha", 1)) + assert not any("has_member" in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "precondition", + [ + pytest.param({"has_member": {"equals_string": "alpha", "pattern": "^a"}}, id="inside-has_member"), + pytest.param({"has_member": {"equals_string": "alpha"}, "minimum_cardinality": 2}, id="beside-has_member"), + ], +) +def test_exclusive_value_has_member_extra_operator_skipped(caplog, precondition): + """has_member is exact too: an operator next to its equals_string, or next + to has_member itself, makes the rule untranslatable.""" + precondition = json.dumps(precondition) + schema = _EXCLUSIVE_RANGE_SCHEMA_YAML.format(range="Tag", precondition=precondition, max_card=1) + with caplog.at_level(logging.WARNING): + assert _sparql_queries(_parse_shacl(schema), EX_EXR.Thing) == [] + assert any("match none of the translated patterns" in rec.message for rec in caplog.records), caplog.text + + +def test_exclusive_value_on_non_string_range_skipped(caplog): + """equals_string on a slot that does not hold strings is skipped with a + warning in the exclusive-value pattern too: the integer ``3`` never equals + the string "3", so the constraint would never fire.""" + schema = _exclusive_schema("integer", "3", 1) + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + + assert _sparql_queries(g, EX_EXR.Thing) == [] + assert any("whose range 'integer' is neither an enum" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_non_operator_fields_are_the_metamodel_element_metadata(): + """The fields the operator accounting ignores are exactly the metamodel's + ``element`` slots that are not ``slot_expression`` slots.""" + from linkml_runtime.utils.formatutils import underscore + from linkml_runtime.utils.introspection import package_schemaview + + metamodel = package_schemaview("linkml_runtime.linkml_model.meta") + element_slots = {underscore(s) for s in metamodel.class_slots("element")} + operator_slots = {underscore(s) for s in metamodel.class_slots("slot_expression")} + + assert ShaclGenerator._NON_OPERATOR_FIELDS == element_slots - operator_slots + assert {"description", "annotations", "title"} <= ShaclGenerator._NON_OPERATOR_FIELDS + + +def test_rule_condition_metadata_does_not_block_translation(): + """Metadata on a condition does not change which instances satisfy it, + so it does not make an otherwise recognised rule untranslatable.""" + schema = _rule_target_schema( + "string", + "x", + guard_extra={"title": "Guard set"}, + target_extra={"description": "The target must then be x.", "comments": ["Metadata only."]}, + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + + +# ==================================================================== + +# Rule inheritance +# +# A rule applies to "all members of this class" (metamodel `rules`), so a +# class shape carries the rules of its ancestors and mixins, each translated +# in the class's own context. +# ==================================================================== + + +_RULE_INHERITANCE_SCHEMA_YAML = """ +id: https://example.org/rule-inheritance +name: rule_inheritance +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/rule-inheritance/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Shade: + permissible_values: + Red: + meaning: ex:ShadeRed +slots: + guard: {} + target: + range: Color + code: {} + label: {} +classes: + Base: + class_uri: ex:Base + slots: [guard, target] + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: Red + Child: + is_a: Base + class_uri: ex:Child + GrandChild: + is_a: Child + class_uri: ex:GrandChild + Shaded: + is_a: Base + class_uri: ex:Shaded + slot_usage: + target: + range: Shade + Counted: + is_a: Base + class_uri: ex:Counted + slot_usage: + target: + range: integer + Labelled: + mixin: true + class_uri: ex:Labelled + slots: [code, label] + rules: + - preconditions: + slot_conditions: + code: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: x + Mixed: + class_uri: ex:Mixed + mixins: [Labelled] + SameUri: + is_a: Base + class_uri: ex:Base +""" + +EX_RI = rdflib.Namespace("https://example.org/rule-inheritance/") + + +def test_rule_inherited_by_descendant_shapes(caplog): + """The shape of a class carries the rules of its ancestors and mixins, each + translated in the class's own context: a ``slot_usage`` that narrows the + target to another enum resolves the value there, and one that narrows it to + a non-string range skips the rule for that class only. A class sharing its + ancestor's ``class_uri`` shares its shape, which carries the rule once.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_RULE_INHERITANCE_SCHEMA_YAML) + + for cls in ("Base", "Child", "GrandChild", "Shaded", "Labelled", "Mixed"): + assert len(_sparql_queries(g, EX_RI[cls])) == 1, cls + assert f"<{EX_RI.ShadeRed}>" in _sparql_queries(g, EX_RI.Shaded)[0] + assert _sparql_queries(g, EX_RI.Counted) == [] + assert any("'Counted'" in rec.message and "is neither an enum" in rec.message for rec in caplog.records) + + +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param('ex:x a ex:Child ; ex:guard "g" ; ex:target ex:Red .', False, id="child-ok"), + pytest.param('ex:x a ex:Child ; ex:guard "g" ; ex:target ex:Blue .', True, id="child-violates"), + pytest.param('ex:x a ex:GrandChild ; ex:guard "g" ; ex:target ex:Blue .', True, id="grandchild-violates"), + pytest.param('ex:x a ex:Shaded ; ex:guard "g" ; ex:target ex:ShadeRed .', False, id="narrowed-ok"), + pytest.param('ex:x a ex:Shaded ; ex:guard "g" ; ex:target ex:Red .', True, id="narrowed-violates"), + pytest.param('ex:x a ex:Mixed ; ex:code "c" ; ex:label "x" .', False, id="mixin-ok"), + pytest.param('ex:x a ex:Mixed ; ex:code "c" ; ex:label "y" .', True, id="mixin-violates"), + ], +) +def test_rule_inherited_rule_enforced_on_descendant_instances(instance, violates): + """An instance typed only with a descendant class is held to the inherited rule.""" + data = f"@prefix ex: .\n{instance}" + _, focus_nodes = _validate_rules(_RULE_INHERITANCE_SCHEMA_YAML, data) + assert focus_nodes == ({EX_RI.x} if violates else set()) + + +def test_rule_shared_shape_reports_once(): + """Two classes sharing a ``class_uri`` share one shape, so an instance + violating the rule both inherit gets one result, not one per class.""" + data = '@prefix ex: .\nex:x a ex:Base ; ex:guard "g" ; ex:target ex:Blue .' + _, results = _rule_results(_RULE_INHERITANCE_SCHEMA_YAML, data) + assert results == [(EX_RI.x, EX_RI.target, EX_RI.Blue)] + + +# ==================================================================== + +# Slot resolution and value rendering +# +# A rule's SPARQL body must query the same IRI that ``sh:path`` emits for the +# slot, and resolve the slot's range in the class: +# 1. a slot_usage `slot_uri` (or enum `range`) override applies to the rule; +# 2. an alias-form key (``my_slot`` for slot ``my slot``) names the slot; +# 3. a slot without a range takes the schema's `default_range`; +# 4. built-in type names resolve without importing linkml:types; +# 5. a condition on an unknown or identifier slot makes the rule untranslatable. +# ==================================================================== + + +_ENUM_NARROWING_SCHEMA_YAML = """ +id: https://example.org/enum-narrowing +name: enum_narrowing_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/enum-narrowing/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +enums: + BaseMode: + permissible_values: + Active: + meaning: ex:GLOBAL_Active + SceneMode: + permissible_values: + Active: + meaning: ex:LOCAL_Active + +slots: + activator: + range: string + slot_uri: ex:activator + mode: + range: BaseMode + slot_uri: ex:mode + +classes: + Scene: + class_uri: ex:Scene + slots: + - activator + - mode + slot_usage: + mode: + range: SceneMode + rules: + - description: If an activator is present the mode must be Active. + preconditions: + slot_conditions: + activator: + value_presence: PRESENT + postconditions: + slot_conditions: + mode: + equals_string: Active +""" + +EX_EN = rdflib.Namespace("https://example.org/enum-narrowing/") + + +def test_rule_enum_range_narrowed_by_slot_usage(): + """A slot_usage range override to a class-specific enum must resolve the + value's meaning against the induced (narrowed) enum, not the base range.""" + queries = _sparql_queries(_parse_shacl(_ENUM_NARROWING_SCHEMA_YAML), EX_EN.Scene) + assert len(queries) == 1 + query = queries[0] + assert str(EX_EN.LOCAL_Active) in query, f"must resolve the narrowed enum meaning, got:\n{query}" + assert "GLOBAL_Active" not in query, f"must not resolve the base enum meaning, got:\n{query}" + + +_SLOT_RESOLUTION_SCHEMA_YAML = """ +id: https://example.org/slot-resolution +name: slot_resolution +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/slot-resolution/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + guard: {} + my slot: {} + target: {} +classes: + Thing: + class_uri: ex:Thing + slots: [guard, my slot, target] + slot_usage: + target: + slot_uri: ex:narrowed_target + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: x + - preconditions: + slot_conditions: + my_slot: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: x +""" + +EX_SR = rdflib.Namespace("https://example.org/slot-resolution/") + + +def test_rule_slot_iris_match_sh_path(): + """Every predicate a rule queries is one that ``sh:path`` emits: a + slot_usage ``slot_uri`` override and an alias-form key resolve to the + slot's own IRI rather than a fabricated one the data never uses.""" + g = _parse_shacl(_SLOT_RESOLUTION_SCHEMA_YAML) + + paths = {str(path) for path in g.objects(None, SH.path)} + queries = _sparql_queries(g, EX_SR.Thing) + assert len(queries) == 2 + for query in queries: + assert set(re.findall(r"<([^>]+)>", query)) <= paths, query + assert f"<{EX_SR.narrowed_target}>" in query, query + assert any(f"<{EX_SR.my_slot}>" in query for query in queries) + + _, focus_nodes = _validate_rules( + _SLOT_RESOLUTION_SCHEMA_YAML, + "@prefix ex: .\n" + 'ex:x a ex:Thing ; ex:my_slot "s" ; ex:narrowed_target "y" .', + ) + assert focus_nodes == {EX_SR.x} + + +_RANGE_RESOLUTION_SCHEMA_YAML = """ +id: https://example.org/range-resolution +name: range_resolution +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/range-resolution/ +imports: + - linkml:types +default_prefix: ex +default_range: {default_range} +slots: + guard: + range: string + target: {target_slot} +classes: + Thing: + class_uri: ex:Thing + slots: {class_slots} + slot_usage: {slot_usage} + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: "3" +""" + +EX_RR = rdflib.Namespace("https://example.org/range-resolution/") + + +@pytest.mark.parametrize( + "default_range,target_slot,class_slots,slot_usage,translated", + [ + pytest.param("string", "{}", "[guard, target]", "{}", True, id="default-range-string"), + pytest.param("integer", "{}", "[guard, target]", "{}", False, id="default-range-integer"), + pytest.param( + "string", + "{range: integer}", + "[guard, target]", + "{target: {range: string}}", + True, + id="slot-usage-to-string", + ), + pytest.param( + "string", "{}", "[guard, target]", "{target: {range: integer}}", False, id="slot-usage-to-integer" + ), + pytest.param("string", "{}", "[guard]", "{}", True, id="slot-not-on-class-default-string"), + pytest.param("integer", "{}", "[guard]", "{}", False, id="slot-not-on-class-default-integer"), + ], +) +def test_rule_target_range_resolved_in_class_context(default_range, target_slot, class_slots, slot_usage, translated): + """Whether the target holds strings is decided by its range in the class: + the schema's ``default_range`` for a slot without one, as refined by + ``slot_usage``, also for a slot the class does not declare.""" + schema = _RANGE_RESOLUTION_SCHEMA_YAML.format( + default_range=default_range, target_slot=target_slot, class_slots=class_slots, slot_usage=slot_usage + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_RR.Thing)) == (1 if translated else 0) + if translated: + prefix = "@prefix ex: .\n" + assert _validate_rules(schema, f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target "3" .')[1] == set() + assert _validate_rules(schema, f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target "4" .')[1] == {EX_RR.x} + + +EX_SINGLE = rdflib.Namespace("https://example.org/single-rule/") + + +def _single_rule_schema(rule: dict, attributes: dict | None = None, *, imports: bool = True) -> str: + """A schema whose class ``Thing`` has *attributes* and the one *rule* (JSON, which is YAML). + + By default the attributes are the string slots ``guard``, ``target`` and + ``other``, the boolean ``flag`` and the multivalued ``tags``. Without + *imports* the schema does not import ``linkml:types`` and has no + ``default_range``. + """ + schema = { + "id": "https://example.org/single-rule", + "name": "single_rule", + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": str(EX_SINGLE)}, + "default_prefix": "ex", + "classes": { + "Thing": { + "class_uri": "ex:Thing", + "attributes": attributes + or { + "guard": {"range": "string"}, + "target": {"range": "string"}, + "other": {"range": "string"}, + "flag": {"range": "boolean"}, + "tags": {"range": "string", "multivalued": True}, + }, + "rules": [rule], + } + }, + } + if imports: + schema["imports"] = ["linkml:types"] + schema["default_range"] = "string" + return json.dumps(schema) + + +def _rule(pre: dict, post: dict, **rule_fields) -> dict: + """A rule with the slot conditions *pre* and *post*.""" + return {"preconditions": {"slot_conditions": pre}, "postconditions": {"slot_conditions": post}, **rule_fields} + + +_GUARD_PRESENT = {"guard": {"value_presence": "PRESENT"}} + + +def _assert_skipped(caplog, schema: str, reason: str) -> None: + """Generating *schema* emits no rule constraint and warns that the rule was skipped for *reason*.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + assert _sparql_queries(g, EX_SINGLE.Thing) == [] + assert any("skipped, because" in rec.message and reason in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "target_range,value,allowed,other", + [ + pytest.param("boolean", "true", "true", "false", id="boolean-guard"), + pytest.param("string", "x", '"x"', '"y"', id="string"), + pytest.param("curie", "ex:a", '"ex:a"', '"ex:b"', id="curie"), + pytest.param(None, "x", '"x"', '"y"', id="no-range"), + ], +) +def test_rule_builtin_types_without_imports(target_range, value, allowed, other): + """In a schema that does not import ``linkml:types``, a built-in range name + resolves as the main slot loop resolves it, so the boolean guard and the + string comparison are both translated. A slot with no range at all (and + no ``default_range``) holds untyped values, compared as strings as the + JSON Schema generator compares them.""" + target = {} if target_range is None else {"range": target_range} + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": value}}), + {"guard": {"range": "string"}, "target": target}, + imports=False, + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_SINGLE.Thing)) == 1 + prefix = "@prefix ex: .\n" + for term, violates in ((allowed, False), (other, True)): + data = f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target {term} .' + assert _validate_rules(schema, data)[1] == ({EX_SINGLE.x} if violates else set()) + + +def test_rule_builtin_non_string_type_without_imports_skipped(caplog): + """A built-in non-string range without the ``linkml:types`` import is skipped like an imported one.""" + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": "3"}}), + {"guard": {"range": "string"}, "target": {"range": "integer"}}, + imports=False, + ) + _assert_skipped(caplog, schema, "is neither an enum nor a type with datatype xsd:string or xsd:boolean") + + +def test_curie_slot_without_imports_has_xsd_string_datatype(): + """A ``curie`` slot in a schema without the ``linkml:types`` import gets + ``sh:datatype xsd:string``, as with the import.""" + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": "ex:a"}}), + {"guard": {"range": "string"}, "target": {"range": "curie"}}, + imports=False, + ) + g = _parse_shacl(schema) + datatypes = { + o + for p in g.objects(EX_SINGLE.Thing, SH.property) + if (p, SH.path, EX_SINGLE.target) in g + for o in g.objects(p, SH.datatype) + } + assert datatypes == {XSD.string} + + +def test_rule_dynamic_enum_value_not_reported(caplog): + """An enum without static permissible values (a dynamic enum) can hold + values the schema does not list, so its equals_string value is not + reported as unknown.""" + + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "GO:0008150"}}))) + schema["enums"] = {"Process": {"reachable_from": {"source_ontology": "obo:go", "source_nodes": ["GO:0008150"]}}} + schema["classes"]["Thing"]["attributes"]["target"] = {"range": "Process"} + with caplog.at_level(logging.WARNING): + queries = _sparql_queries(_parse_shacl(json.dumps(schema)), EX_SINGLE.Thing) + assert len(queries) == 1 and '"GO:0008150"' in queries[0] + assert not any("permissible value" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_message_follows_default_language(): + """The rule description becomes ``sh:message``, tagged with the default + language like every other human-readable literal the generator emits.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}, description="The target must be x.") + g = _parse_shacl(_single_rule_schema(rule), default_language="en") + (node,) = g.objects(EX_SINGLE.Thing, SH.sparql) + assert set(g.objects(node, SH.message)) == {Literal("The target must be x.", lang="en")} + + +@pytest.mark.parametrize( + "descriptions,constraints", + [ + pytest.param(["Same.", "Same."], 1, id="identical-rules"), + pytest.param(["First.", "Second."], 2, id="different-messages"), + ], +) +def test_rule_identical_constraints_emitted_once(descriptions, constraints): + """A shape carries an identical constraint (query and message) once; rules + that differ in their message are kept apart.""" + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}))) + rule = schema["classes"]["Thing"]["rules"][0] + schema["classes"]["Thing"]["rules"] = [{**rule, "description": text} for text in descriptions] + assert len(list(_parse_shacl(json.dumps(schema)).objects(EX_SINGLE.Thing, SH.sparql))) == constraints + + +def test_rule_problem_reported_once_for_inheriting_classes(caplog): + """A problem with a rule is reported once, naming the declaring class and + the rule's position, however many classes inherit it; and a skipped rule + with elseconditions is not also reported as partially emitted.""" + + schema = json.loads( + _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}, elseconditions={"slot_conditions": {}}) + ) + ) + for name in ("C1", "C2", "C3"): + schema["classes"][name] = {"is_a": "Thing", "class_uri": f"ex:{name}"} + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1, messages + assert "skipped, because" in messages[0] and "(in the shapes of 'Thing', 'C1', 'C2', 'C3')" in messages[0] + assert not any("elseconditions" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_problem_names_every_affected_shape_whatever_the_class_order(caplog): + """A problem with an inherited rule names every class shape it affects, so + the report does not depend on the order in which classes are declared.""" + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}))) + schema["classes"] = {"Sub": {"is_a": "Thing", "class_uri": "ex:Sub"}, **schema["classes"]} + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1, messages + assert "'Sub'" in messages[0] and "'Thing'" in messages[0], messages[0] + + +def test_rule_problems_reported_per_rule_and_per_problem(caplog): + """Problems are kept apart by rule and by kind: two rules with the same + problem, and one rule with two problems, each produce their own warning. + Each warning names the rule by its position and description.""" + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}))) + schema["enums"] = {"Color": {"permissible_values": {"Red": {}, "Blue": {}}}} + schema["classes"]["Thing"]["attributes"]["color"] = {"range": "Color"} + schema["classes"]["Thing"]["rules"] = [ + _rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}, description="First"), + _rule(_GUARD_PRESENT, {"target": {"pattern": "^y"}}, description="Second"), + _rule( + _GUARD_PRESENT, + {"color": {"equals_string": "Purple"}}, + description="Third", + elseconditions={"slot_conditions": {"color": {"equals_string": "Red"}}}, + ), + ] + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "of class 'Thing'" in rec.message] + assert len([m for m in messages if m.startswith("Rule 1 of class 'Thing' (First): skipped")]) == 1, messages + assert len([m for m in messages if m.startswith("Rule 2 of class 'Thing' (Second): skipped")]) == 1, messages + third = [m for m in messages if m.startswith("Rule 3 of class 'Thing' (Third)")] + assert len(third) == 2 and any("elseconditions" in m for m in third), messages + assert any("'Purple', which is not a permissible value" in m for m in third), messages + + +def test_rule_first_unknown_slot_is_the_reported_problem(caplog): + """A rule is skipped at its first untranslatable condition: with an + unknown guard and an unknown target, only the guard is reported.""" + schema = _single_rule_schema( + _rule({"no_such_guard": {"value_presence": "PRESENT"}}, {"no_such_target": {"equals_string": "x"}}) + ) + with caplog.at_level(logging.WARNING): + _parse_shacl(schema) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1 and "no_such_guard" in messages[0], messages + + +def test_exclusive_value_bare_equals_string_on_single_valued_slot_does_not_warn(caplog): + """On a single-valued slot, "the value equals V" and "one of the values + equals V" coincide, so the bare form is translated without a warning.""" + schema = _single_rule_schema(_rule({"target": {"equals_string": "x"}}, {"target": {"maximum_cardinality": 1}})) + with caplog.at_level(logging.WARNING): + assert len(_sparql_queries(_parse_shacl(schema), EX_SINGLE.Thing)) == 1 + assert not any("has_member" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_message_follows_rule_language(): + """A rule's own ``in_language`` takes precedence over the default language.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}, description="Das Ziel muss x sein.") + rule["in_language"] = "de" + g = _parse_shacl(_single_rule_schema(rule), default_language="en") + (node,) = g.objects(EX_SINGLE.Thing, SH.sparql) + assert set(g.objects(node, SH.message)) == {Literal("Das Ziel muss x sein.", lang="de")} + + +@pytest.mark.parametrize( + "rule", + [ + pytest.param( + _rule({"no_such_slot": {"value_presence": "PRESENT"}}, {"flag": {"equals_string": "true"}}), + id="boolean-guard-unknown-guard", + ), + pytest.param( + _rule({"no_such_slot": {"value_presence": "PRESENT"}}, {"target": {"equals_string": "x"}}), + id="presence-implies-value-unknown-guard", + ), + pytest.param( + _rule(_GUARD_PRESENT, {"no_such_slot": {"equals_string": "x"}}), + id="presence-implies-value-unknown-target", + ), + pytest.param( + _rule({"no_such_slot": {"equals_string": "x"}}, {"no_such_slot": {"maximum_cardinality": 1}}), + id="exclusive-value-unknown-slot", + ), + ], +) +def test_rule_unknown_slot_key_skipped(caplog, rule): + """A rule whose condition names a slot that does not exist is skipped: + a fabricated predicate would make a constraint that never fires (unknown + guard) or always fires (unknown target).""" + _assert_skipped(caplog, _single_rule_schema(rule), "'no_such_slot', which is not a slot") + + +@pytest.mark.parametrize( + "rule", + [ + pytest.param(_rule({"id": {"value_presence": "PRESENT"}}, {"target": {"equals_string": "x"}}), id="guard"), + pytest.param(_rule(_GUARD_PRESENT, {"id": {"equals_string": "x"}}), id="target"), + ], +) +def test_rule_identifier_slot_skipped(caplog, rule): + """The identifier is the node's IRI, not a property arc, so a condition on + it can never match a triple and the rule is skipped.""" + attributes = {"id": {"identifier": True, "range": "uriorcurie"}, "guard": {}, "target": {}} + _assert_skipped(caplog, _single_rule_schema(rule, attributes), "identifier slot 'id'") + + +_ESCAPING_SCHEMA_YAML = """ +id: https://example.org/escaping +name: escaping_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/escaping/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +slots: + trigger: + range: string + slot_uri: ex:trigger + label: + range: string + slot_uri: ex:label + +classes: + Item: + class_uri: ex:Item + slots: + - trigger + - label + rules: + - description: If a trigger is present the label must equal the quoted marker. + preconditions: + slot_conditions: + trigger: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: 'a"b\\\\c' +""" + +EX_ESC = rdflib.Namespace("https://example.org/escaping/") + + +def test_rule_equals_string_special_chars_escaped(): + """An equals_string value with a quote and backslash must be escaped so the + generated SPARQL stays syntactically valid (no injection / broken query).""" + from rdflib.plugins.sparql import prepareQuery + + queries = _sparql_queries(_parse_shacl(_ESCAPING_SCHEMA_YAML), EX_ESC.Item) + assert len(queries) == 1 + query = queries[0] + + # Would raise ParseException on the unescaped `... = "a"b\c"` form. + prepareQuery(query) + assert '\\"' in query, f"double quote must be escaped, got:\n{query}" + assert "\\\\" in query, f"backslash must be escaped, got:\n{query}" + + +# ==================================================================== + +# Operator exactness +# +# A rule is translated only when its conditions set exactly the operators a +# pattern translates. Each rule below would match a pattern if the converter +# dropped the extra operator, which would widen the precondition (false +# positives) or weaken the postcondition (false negatives). +# ==================================================================== + + +@pytest.mark.parametrize( + "extra,reason", + [ + pytest.param({"pattern": "^x"}, "pattern", id="pattern"), + pytest.param({"minimum_value": 0}, "needs a type with a numeric datatype", id="minimum_value"), + pytest.param({"maximum_value": 5}, "needs a type with a numeric datatype", id="maximum_value"), + pytest.param({"recommended": True}, "recommended", id="recommended"), + pytest.param({"minimum_cardinality": 1}, "minimum_cardinality", id="minimum_cardinality"), + pytest.param({"exact_cardinality": 1}, "exact_cardinality", id="exact_cardinality"), + pytest.param({"equals_number": 3}, "equals_number", id="equals_number"), + pytest.param({"any_of": [{"equals_string": "x"}]}, "any_of", id="slot-any_of"), + ], +) +@pytest.mark.parametrize("condition", ["guard", "target"]) +def test_rule_extra_condition_operator_skipped(caplog, condition, extra, reason): + """A guard or target condition that adds an operator no translation + supports, or a bound on a string slot, makes the rule untranslatable: + dropping it would widen or weaken the rule.""" + guard = {"value_presence": "PRESENT", **(extra if condition == "guard" else {})} + target = {"equals_string": "x", **(extra if condition == "target" else {})} + schema = _single_rule_schema(_rule({"guard": guard}, {"target": target})) + _assert_skipped(caplog, schema, reason) + + +@pytest.mark.parametrize( + "guard_extra,target_extra", + [ + pytest.param({"required": True}, None, id="guard-required"), + pytest.param(None, {"required": True}, id="target-required"), + ], +) +@pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize("instance,valid_closed_world,valid_open_world", _RULE_INSTANCES) +def test_rule_extra_operator_translated_exactly( + guard_extra, target_extra, open_world, instance, valid_closed_world, valid_open_world +): + """An extra operator the composed translation supports is translated, not + dropped: a redundant ``required: true`` on the guard changes nothing, and on + the target it requires the target even in an open world.""" + obj = {key: text.format(value="x", other="y") for key, text in instance.items()} + schema = _rule_target_schema( + "string", "x", open_world=open_world, guard_extra=guard_extra, target_extra=target_extra + ) + target_required = bool(target_extra and target_extra.get("required")) + valid = valid_closed_world if target_required or not open_world else valid_open_world + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + assert _json_schema_and_shacl_verdicts(schema, "Thing", obj) == (valid, valid) + + +@pytest.mark.parametrize( + "presence,instance,valid", + [ + pytest.param("ABSENT", {"guard": "g", "target": "y"}, True, id="absent-guard-present"), + pytest.param("ABSENT", {"target": "y"}, False, id="absent-other"), + pytest.param("ABSENT", {"target": "x"}, True, id="absent-allowed"), + pytest.param("ABSENT", {}, False, id="absent-target-absent"), + pytest.param("UNCOMMITTED", {"guard": "g", "target": "y"}, False, id="uncommitted-other"), + pytest.param("UNCOMMITTED", {"target": "x"}, True, id="uncommitted-allowed"), + pytest.param("UNCOMMITTED", {}, False, id="uncommitted-target-absent"), + ], +) +def test_rule_guard_presence_other_than_present_composed(presence, instance, valid): + """A guard with ``value_presence: ABSENT`` triggers the rule when the guard + is absent, one with ``UNCOMMITTED`` always; the named pattern, which reads + ``PRESENT``, does not match them, and the composed translation does, as in + JSON Schema.""" + schema = _rule_target_schema("string", "x", guard_extra={"value_presence": presence}) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert "?guard" not in query, query # composed, not the named pattern + assert _json_schema_and_shacl_verdicts(schema, "Thing", instance) == (valid, valid) + + +@pytest.mark.parametrize("operator", ["any_of", "all_of", "exactly_one_of", "none_of"]) +@pytest.mark.parametrize("side", ["preconditions", "postconditions"]) +def test_rule_expression_level_operator_skipped(caplog, side, operator): + """An expression-level boolean operator on either side cannot be honoured + by any pattern; dropping it would widen or weaken the rule.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}) + rule[side][operator] = [{"slot_conditions": {"other": {"equals_string": "y"}}}] + _assert_skipped(caplog, _single_rule_schema(rule), f"its {side} use {operator}") + + +@pytest.mark.parametrize( + "allowed,target,valid", + [ + pytest.param(["x", "y"], "x", True, id="in-both"), + pytest.param(["x", "y"], "y", False, id="in-one"), + pytest.param(["y"], "x", False, id="contradictory"), + pytest.param(["y"], "y", False, id="contradictory-other"), + ], +) +def test_rule_post_with_both_equals_forms_is_a_conjunction(allowed, target, valid): + """equals_string and equals_string_in set together must both hold, as in + JSON Schema (``const`` and ``enum``); neither form silently wins.""" + schema = _rule_target_schema("string", "x", target_extra={"equals_string_in": allowed}) + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + assert _json_schema_and_shacl_verdicts(schema, "Thing", {"guard": "g", "target": target}) == (valid, valid) + + +def test_rule_exclusive_value_extra_operator_skipped(caplog): + """The exclusive-value pattern is exact too: a precondition mixing + equals_string with an unsupported operator is skipped.""" + schema = _single_rule_schema( + _rule({"tags": {"equals_string": "x", "pattern": "^x"}}, {"tags": {"maximum_cardinality": 1}}) + ) + _assert_skipped(caplog, schema, "match none of the translated patterns") + + +_STRING_TRUE_SCHEMA_YAML = """ +id: https://example.org/string-true +name: string_true +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/string-true/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + opt: + slot_uri: ex:opt + status: + range: string + slot_uri: ex:status +classes: + Conf: + class_uri: ex:Conf + slots: [opt, status] + rules: + - description: If opt is present, status must be the string "true". + preconditions: + slot_conditions: + opt: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string: "true" +""" + + +def test_rule_equals_true_on_string_slot_uses_piv(): + """equals_string "true" on a NON-boolean slot is compared as the string + "true", not as the boolean of the boolean guard.""" + queries = _sparql_queries(_parse_shacl(_STRING_TRUE_SCHEMA_YAML), URIRef("https://example.org/string-true/Conf")) + assert len(queries) == 1 + assert '?value = "true"' in queries[0], f"string-range 'true' must be a string comparison, got:\n{queries[0]}" + assert "?value = true" not in queries[0], "the boolean guard must not apply to a string slot" + + +@pytest.mark.parametrize( + "status,violates", + [ + pytest.param('"true"', False, id="plain"), + pytest.param('"true"^^xsd:string', False, id="xsd-string"), + pytest.param('"other"', True, id="other"), + ], +) +def test_rule_equals_true_on_string_slot_pyshacl_end_to_end(status, violates): + """status "true" (string) satisfies the rule in either RDF 1.1-identical form.""" + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + f'ex:x a ex:Conf ; ex:opt "x" ; ex:status {status} .' + ) + _, focus_nodes = _validate_rules(_STRING_TRUE_SCHEMA_YAML, data) + assert focus_nodes == ({URIRef("https://example.org/string-true/x")} if violates else set()) + + +# ==================================================================== + +# Compositional fallback +# +# A rule no named pattern matches is composed from its operators. Whether a +# condition requires its slot follows the JSON Schema generator: value_presence, +# then required, then a default (preconditions do, postconditions unless +# open_world, inner conditions of a nested expression do not). Each value of a +# slot must satisfy the condition: a slot constraint applies to all members of +# a collection (05validation.md), as the JSON Schema generator's `items` reads +# it; has_member requires some value to satisfy its condition. +# ==================================================================== + + +EX_COMP = rdflib.Namespace("https://example.org/compose/") +_COMP_PREFIXES = "@prefix ex: .\n@prefix xsd: .\n" + + +def _compose_schema( + rule: dict, + *, + part_usage: dict | None = None, + thing_usage: dict | None = None, + slots: list[str] | None = None, + subclass: bool = False, +) -> str: + """A schema whose class ``Thing`` has the slots used by the compositional tests and the one *rule*. + + ``Part`` is the inlined class of ``part`` / ``parts`` and of its own + ``sub``; ``SpecialPart`` narrows its ``kind``. ``site`` references a + ``Site`` by its identifier. *part_usage* and + *thing_usage* add ``slot_usage``, *slots* adds schema slots to ``Thing``, + and *subclass* adds ``SubThing``, which inherits the rule. + """ + schema = { + "id": "https://example.org/compose", + "name": "compose", + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": str(EX_COMP), "xsd": str(XSD)}, + "imports": ["linkml:types"], + "default_prefix": "ex", + "default_range": "string", + "enums": { + "Kind": {"permissible_values": {"Plain": {}, "Special": {"meaning": "ex:Special"}}}, + "SpecialKind": {"permissible_values": {"Special": {"meaning": "ex:VerySpecial"}}}, + }, + "slots": {"kind": {"range": "Kind"}}, + "classes": { + "Part": { + "class_uri": "ex:Part", + "slots": ["kind"], + "attributes": { + "depth": {"range": "integer"}, + "label": {}, + "marks": {"range": "integer", "multivalued": True}, + "sub": {"range": "Part", "inlined": True}, + }, + "slot_usage": part_usage or {}, + }, + "SpecialPart": { + "is_a": "Part", + "class_uri": "ex:SpecialPart", + "slot_usage": {"kind": {"range": "SpecialKind"}}, + }, + "Site": { + "class_uri": "ex:Site", + "attributes": {"id": {"identifier": True, "range": "uriorcurie"}, "depth": {"range": "integer"}}, + }, + "Thing": { + "class_uri": "ex:Thing", + "slots": slots or [], + "attributes": { + "mode": {}, + "flag": {"range": "boolean"}, + "level": {"range": "integer"}, + "levels": {"range": "integer", "multivalued": True}, + "note": {}, + "part": {"range": "Part", "inlined": True}, + "parts": {"range": "Part", "inlined": True, "multivalued": True, "inlined_as_list": True}, + "site": {"range": "Site"}, + }, + "slot_usage": thing_usage or {}, + "rules": [rule], + }, + }, + } + if subclass: + schema["classes"]["SubThing"] = {"is_a": "Thing", "class_uri": "ex:SubThing"} + return json.dumps(schema) + + +_REQUIRE_NOTE = {"note": {"required": True}} +_DEPTH_AT_MOST_ZERO = {"range_expression": {"slot_conditions": {"depth": {"maximum_value": 0}}}} +_IF_MODE_M = {"mode": {"equals_string": "m"}} +_LEVEL_10_TO_20 = {"level": {"minimum_value": 10, "maximum_value": 20}} + + +def _inner(**conditions: dict) -> dict: + """A ``range_expression`` with the slot *conditions* on the slot's class.""" + return {"range_expression": {"slot_conditions": conditions}} + + +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +@pytest.mark.parametrize( + "pre,post,instance,valid", + [ + # equals_string, and presence in the postcondition + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "m"}, False, id="equals-required"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "m", "note": "n"}, True, id="equals-met"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "x"}, True, id="equals-not-triggered"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {}, True, id="precondition-slot-absent"), + pytest.param(_IF_MODE_M, {"note": {"value_presence": "PRESENT"}}, {"mode": "m"}, False, id="presence-post"), + pytest.param(_IF_MODE_M, {"note": {}}, {"mode": "m"}, False, id="empty-post-required-by-default"), + pytest.param( + _IF_MODE_M, {"note": {"value_presence": "ABSENT"}}, {"mode": "m", "note": "n"}, False, id="absent-post" + ), + pytest.param(_IF_MODE_M, {"note": {"value_presence": "ABSENT"}}, {"mode": "m"}, True, id="absent-met"), + # numeric bounds are inclusive + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 15}, False, id="bounds-inside"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 10}, False, id="bounds-at-minimum"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 20}, False, id="bounds-at-maximum"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 25}, True, id="bounds-above"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 5}, True, id="bounds-below"), + # presence in the precondition + pytest.param({"level": {"value_presence": "PRESENT"}}, _REQUIRE_NOTE, {"level": 1}, False, id="presence-pre"), + pytest.param({"mode": {"required": True}}, _REQUIRE_NOTE, {"mode": "m"}, False, id="required-pre"), + pytest.param({"mode": {"required": True}}, _REQUIRE_NOTE, {}, True, id="required-pre-absent"), + pytest.param({"mode": {}}, _REQUIRE_NOTE, {"mode": "x"}, False, id="empty-pre-requires-presence"), + pytest.param({"mode": {}}, _REQUIRE_NOTE, {}, True, id="empty-pre-absent"), + pytest.param({"mode": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE, {}, False, id="absent-pre"), + pytest.param( + {"mode": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE, {"mode": "m"}, True, id="absent-pre-present" + ), + pytest.param( + {"mode": {"required": False, "equals_string": "m"}}, _REQUIRE_NOTE, {}, False, id="optional-pre-absent" + ), + pytest.param( + {"mode": {"required": False, "equals_string": "m"}}, _REQUIRE_NOTE, {"mode": "x"}, True, id="optional-pre" + ), + pytest.param( + {"mode": {"value_presence": "UNCOMMITTED", "equals_string": "m"}}, + _REQUIRE_NOTE, + {}, + False, + id="uncommitted-pre-absent", + ), + pytest.param( + {"mode": {"value_presence": "UNCOMMITTED", "required": True, "equals_string": "m"}}, + _REQUIRE_NOTE, + {}, + False, + id="value-presence-overrides-required", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {}, + True, + id="presence-bound-absent", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {"level": 1}, + True, + id="presence-bound-fails", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {"level": 5}, + False, + id="presence-bound-holds", + ), + # every value of a multivalued slot + pytest.param( + {"levels": {"minimum_value": 10}}, _REQUIRE_NOTE, {"levels": [12, 15]}, False, id="every-value-holds" + ), + pytest.param({"levels": {"minimum_value": 10}}, _REQUIRE_NOTE, {"levels": [12, 3]}, True, id="one-value-fails"), + # a nested range_expression, whose inner conditions hold for an absent inner slot + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {"depth": -1}}, False, id="nested-holds"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {"depth": 5}}, True, id="nested-fails"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {}}, False, id="nested-inner-absent"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {}, True, id="nested-container-absent"), + pytest.param( + {"part": {"required": False, **_DEPTH_AT_MOST_ZERO}}, _REQUIRE_NOTE, {}, False, id="optional-container" + ), + pytest.param( + {"part": _inner(depth={"maximum_value": 0, "required": True})}, + _REQUIRE_NOTE, + {"part": {}}, + True, + id="nested-inner-required-absent", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "PRESENT"})}, + _REQUIRE_NOTE, + {"part": {}}, + True, + id="nested-inner-present-absent", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "PRESENT"})}, + _REQUIRE_NOTE, + {"part": {"depth": 1}}, + False, + id="nested-inner-present", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "ABSENT"})}, + _REQUIRE_NOTE, + {"part": {}}, + False, + id="nested-inner-absent-holds", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "ABSENT"})}, + _REQUIRE_NOTE, + {"part": {"depth": 1}}, + True, + id="nested-inner-absent-fails", + ), + pytest.param( + {"part": _inner(depth={"minimum_value": -5, "maximum_value": 0})}, + _REQUIRE_NOTE, + {"part": {"depth": 3}}, + True, + id="nested-bounds-above", + ), + pytest.param( + {"part": _inner(sub=_DEPTH_AT_MOST_ZERO)}, + _REQUIRE_NOTE, + {"part": {"sub": {"depth": -1}}}, + False, + id="two-hops", + ), + pytest.param( + {"part": _inner(sub=_DEPTH_AT_MOST_ZERO)}, + _REQUIRE_NOTE, + {"part": {"sub": {"depth": 5}}}, + True, + id="two-hops-fails", + ), + pytest.param( + {"parts": _DEPTH_AT_MOST_ZERO}, + _REQUIRE_NOTE, + {"parts": [{"depth": -1}, {"depth": -2}]}, + False, + id="every-member", + ), + pytest.param( + {"parts": _DEPTH_AT_MOST_ZERO}, + _REQUIRE_NOTE, + {"parts": [{"depth": -1}, {"depth": 5}]}, + True, + id="one-member-fails", + ), + # value operators in a postcondition apply to every value of the slot, which is required + pytest.param( + _IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m", "note": "n"}, True, id="post-equals-met" + ), + pytest.param( + _IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m", "note": "x"}, False, id="post-equals-other" + ), + pytest.param(_IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m"}, False, id="post-equals-absent"), + pytest.param( + _IF_MODE_M, {"note": {"equals_string_in": ["n", "o"]}}, {"mode": "m", "note": "o"}, True, id="post-in-met" + ), + pytest.param( + _IF_MODE_M, + {"note": {"equals_string_in": ["n", "o"]}}, + {"mode": "m", "note": "x"}, + False, + id="post-in-other", + ), + pytest.param(_IF_MODE_M, {"level": {"minimum_value": 3}}, {"mode": "m", "level": 3}, True, id="post-bound-met"), + pytest.param( + _IF_MODE_M, {"level": {"minimum_value": 3}}, {"mode": "m", "level": 2}, False, id="post-bound-fails" + ), + pytest.param( + _IF_MODE_M, {"levels": {"maximum_value": 5}}, {"mode": "m", "levels": [1, 5]}, True, id="post-every-value" + ), + pytest.param( + _IF_MODE_M, {"levels": {"maximum_value": 5}}, {"mode": "m", "levels": [1, 9]}, False, id="post-one-fails" + ), + pytest.param( + _IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {"depth": 0}}, True, id="post-nested-met" + ), + pytest.param( + _IF_MODE_M, + {"part": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "part": {"depth": 1}}, + False, + id="post-nested-fails", + ), + pytest.param(_IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m"}, False, id="post-nested-absent"), + pytest.param( + _IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {}}, True, id="post-nested-inner-absent" + ), + pytest.param( + _IF_MODE_M, + {"parts": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "parts": [{"depth": 0}, {"depth": -1}]}, + True, + id="post-nested-every-member", + ), + pytest.param( + _IF_MODE_M, + {"parts": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "parts": [{"depth": 0}, {"depth": 5}]}, + False, + id="post-nested-one-member-fails", + ), + # equals_string_in in a precondition and an inner condition + pytest.param({"mode": {"equals_string_in": ["m", "n"]}}, _REQUIRE_NOTE, {"mode": "n"}, False, id="pre-in"), + pytest.param({"mode": {"equals_string_in": ["m", "n"]}}, _REQUIRE_NOTE, {"mode": "x"}, True, id="pre-in-other"), + pytest.param( + {"part": _inner(label={"equals_string_in": ["a", "b"]})}, + _REQUIRE_NOTE, + {"part": {"label": "b"}}, + False, + id="inner-in", + ), + # several preconditions are a conjunction + pytest.param( + {**_IF_MODE_M, "level": {"minimum_value": 10}}, + _REQUIRE_NOTE, + {"mode": "m", "level": 12}, + False, + id="two-preconditions", + ), + pytest.param( + {**_IF_MODE_M, "level": {"minimum_value": 10}}, + _REQUIRE_NOTE, + {"mode": "m", "level": 3}, + True, + id="two-preconditions-one-fails", + ), + ], +) +def test_compose_agrees_with_json_schema(pre, post, instance, valid, target_class): + """A composed rule decides each instance as the JSON Schema generator's + if/then does, in the class declaring it and in a subclass.""" + schema = _compose_schema(_rule(pre, post), subclass=True) + assert len(_sparql_queries(_parse_shacl(schema), EX_COMP[target_class])) == 1 + assert _json_schema_and_shacl_verdicts(schema, target_class, instance) == (valid, valid) + + +@pytest.mark.parametrize( + "post,instance,valid", + [ + pytest.param(_REQUIRE_NOTE, {"mode": "m"}, False, id="required-absent"), + pytest.param(_REQUIRE_NOTE, {"mode": "m", "note": "n"}, True, id="required-met"), + pytest.param({"note": {"value_presence": "PRESENT"}}, {"mode": "m"}, False, id="present-absent"), + pytest.param({"note": {"value_presence": "PRESENT"}}, {"mode": "m", "note": "n"}, True, id="present-met"), + pytest.param({"note": {"value_presence": "ABSENT"}}, {"mode": "m", "note": "n"}, False, id="absent-present"), + pytest.param({"note": {"value_presence": "ABSENT"}}, {"mode": "m"}, True, id="absent-met"), + pytest.param({"note": {"equals_string": "n"}}, {"mode": "m"}, True, id="value-omitted"), + pytest.param({"note": {"equals_string": "n"}}, {"mode": "m", "note": "x"}, False, id="value-other"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m"}, True, id="nested-omitted"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {}}, True, id="nested-inner-absent"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {"depth": 1}}, False, id="nested-other"), + ], +) +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +def test_compose_open_world(post, instance, valid, target_class): + """With ``open_world`` a postcondition slot may be omitted unless the + postcondition states its presence, as in JSON Schema; a value present must + still satisfy it.""" + schema = _compose_schema(_rule(_IF_MODE_M, post, open_world=True), subclass=True) + assert _json_schema_and_shacl_verdicts(schema, target_class, instance) == (valid, valid) + + +_SPECIAL_KIND = {"kind": {"equals_string": "Special"}} + + +@pytest.mark.parametrize( + "member,post_presence,open_world,members,violates", + [ + pytest.param(_SPECIAL_KIND, {}, False, "ex:p1 . ex:p1 ex:kind ex:Special", False, id="matching-member"), + pytest.param(_SPECIAL_KIND, {}, False, 'ex:p1 . ex:p1 ex:kind "Plain"', True, id="no-matching-member"), + pytest.param( + _SPECIAL_KIND, + {}, + False, + 'ex:p1, ex:p2 . ex:p1 ex:kind "Plain" . ex:p2 ex:kind ex:Special', + False, + id="one-matching", + ), + pytest.param(_SPECIAL_KIND, {}, False, 'ex:p1 . ex:p1 ex:label "l"', False, id="member-without-kind"), + pytest.param( + {"kind": {"equals_string": "Special", "required": True}}, + {}, + False, + 'ex:p1 . ex:p1 ex:label "l"', + True, + id="member-without-required-kind", + ), + pytest.param( + {"kind": {"value_presence": "ABSENT"}}, + {}, + False, + "ex:p1 . ex:p1 ex:kind ex:Special", + True, + id="inner-absent", + ), + pytest.param( + {"kind": {"value_presence": "ABSENT"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:label "l"', + False, + id="inner-absent-met", + ), + pytest.param( + {**_SPECIAL_KIND, "label": {"equals_string": "l"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:kind ex:Special ; ex:label "x"', + True, + id="two-inner-conditions-one-fails", + ), + pytest.param( + {**_SPECIAL_KIND, "label": {"equals_string": "l"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:kind ex:Special ; ex:label "l"', + False, + id="two-inner-conditions-hold", + ), + pytest.param(_SPECIAL_KIND, {}, False, None, True, id="no-members"), + pytest.param(_SPECIAL_KIND, {}, True, None, False, id="no-members-open-world"), + pytest.param(_SPECIAL_KIND, {"required": True}, True, None, True, id="no-members-open-world-required"), + pytest.param(_SPECIAL_KIND, {"required": False}, False, None, False, id="no-members-not-required"), + pytest.param( + _SPECIAL_KIND, + {"value_presence": "UNCOMMITTED", "required": True}, + False, + None, + False, + id="value-presence-overrides-required", + ), + pytest.param(_SPECIAL_KIND, {"value_presence": "ABSENT"}, False, None, False, id="absent-slot"), + pytest.param( + _SPECIAL_KIND, + {"value_presence": "ABSENT"}, + False, + "ex:p1 . ex:p1 ex:kind ex:Special", + True, + id="absent-slot-present", + ), + pytest.param( + {"sub": _DEPTH_AT_MOST_ZERO}, + {}, + False, + "ex:p1 . ex:p1 ex:sub ex:s1 . ex:s1 ex:depth 0", + False, + id="two-hops", + ), + pytest.param( + {"sub": _DEPTH_AT_MOST_ZERO}, + {}, + False, + "ex:p1 . ex:p1 ex:sub ex:s1 . ex:s1 ex:depth 5", + True, + id="two-hops-fails", + ), + pytest.param( + _SPECIAL_KIND, {}, True, 'ex:p1 . ex:p1 ex:kind "Plain"', True, id="no-matching-member-open-world" + ), + ], +) +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +def test_compose_has_member(member, post_presence, open_world, members, violates, target_class): + """``has_member`` is violated when no member satisfies the member condition. + + A member condition holds for an absent inner slot unless it requires the + slot. Whether ``parts`` must be present is decided as for any + postcondition (``open_world``, ``required``, ``value_presence``). The JSON + Schema generator drops ``has_member`` inside rule conditions, so the + verdicts are checked against explicit expectations. + """ + post = {"parts": {"has_member": _inner(**member), **post_presence}} + schema = _compose_schema(_rule({"mode": {"value_presence": "PRESENT"}}, post, open_world=open_world), subclass=True) + body = f'ex:x a ex:{target_class} ; ex:mode "m"' + ("" if members is None else f" ; ex:parts {members}") + _, focus_nodes = _validate_rules(schema, f"{_COMP_PREFIXES}{body} .") + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +_SOME_LEVEL_10 = {"levels": {"has_member": {"minimum_value": 10}}} +_SOME_LEVEL_10_ALL_20 = {"levels": {"has_member": {"minimum_value": 10}, "maximum_value": 20}} +_PART_WITH_SOME_MARK_10 = {"part": _inner(marks={"has_member": {"minimum_value": 10}})} + + +@pytest.mark.parametrize( + "pre,post,data,violates", + [ + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, " ; ex:levels 3, 12", False, id="post-some"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, " ; ex:levels 3, 4", True, id="post-none"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, "", True, id="post-absent"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, " ; ex:levels 3, 12", True, id="pre-some"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, " ; ex:levels 3, 4", False, id="pre-none"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, "", False, id="pre-absent"), + # with value operators on the same slot, which every value must satisfy + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 12, 15", False, id="post-some-and-all"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 12, 25", True, id="post-some-not-all"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 3, 4", True, id="post-all-not-some"), + # inside a nested condition, on the values of the inner slot + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, " ; ex:part ex:p1 . ex:p1 ex:marks 3, 12", True, id="inner-some" + ), + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, " ; ex:part ex:p1 . ex:p1 ex:marks 3, 4", False, id="inner-none" + ), + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, ' ; ex:part ex:p1 . ex:p1 ex:label "l"', True, id="inner-absent" + ), + pytest.param( + {**_PART_WITH_SOME_MARK_10, "levels": {"minimum_value": 0}}, + _REQUIRE_NOTE, + " ; ex:levels 1 ; ex:part ex:p1 . ex:p1 ex:marks 4 . ex:y a ex:Thing ; ex:part ex:p2 . ex:p2 ex:marks 12", + False, + id="inner-member-of-another-subject", + ), + ], +) +def test_compose_has_member_with_value_operators(pre, post, data, violates): + """``has_member`` holds when some value satisfies its value operators, in a + postcondition, a precondition and a nested condition alike, next to value + operators that every value must satisfy. An absent slot fails a + precondition, which requires its slot, violates a closed-world + postcondition, and satisfies a nested condition, which does not.""" + schema = _compose_schema(_rule(pre, post)) + _, focus_nodes = _validate_rules(schema, f'{_COMP_PREFIXES}ex:x a ex:Thing ; ex:mode "m"{data} .') + assert EX_COMP.x in focus_nodes if violates else EX_COMP.x not in focus_nodes + + +@pytest.mark.parametrize( + "rule,data,expected", + [ + pytest.param( + _rule(_IF_MODE_M, _REQUIRE_NOTE), + 'ex:x a ex:Thing ; ex:mode "m" .', + [(EX_COMP.x, EX_COMP.note, EX_COMP.x)], + id="required", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"value_presence": "ABSENT"}}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:note "n" .', + [(EX_COMP.x, EX_COMP.note, Literal("n"))], + id="absent", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"equals_string": "n"}}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:note "a", "n", "b" .', + [(EX_COMP.x, EX_COMP.note, Literal("a")), (EX_COMP.x, EX_COMP.note, Literal("b"))], + id="each-offending-value", + ), + pytest.param( + _rule(_IF_MODE_M, {"parts": _inner(label={"equals_string": "l"}, depth={"maximum_value": 0})}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:parts ex:p1 . ex:p1 ex:label "x" ; ex:depth 5 .', + [(EX_COMP.x, EX_COMP.parts, EX_COMP.p1)], + id="value-violating-twice-reported-once", + ), + pytest.param( + _rule(_IF_MODE_M, _SOME_LEVEL_10), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:levels 3, 4 .', + [(EX_COMP.x, EX_COMP.levels, EX_COMP.x)], + id="no-member-satisfies", + ), + ], +) +def test_compose_result_names_path_and_value(rule, data, expected): + """A composed result names the postcondition's property and the offending + value, once per value, or the focus node when a value is missing (SHACL + §5.3.2).""" + _, results = _rule_results(_compose_schema(rule), _COMP_PREFIXES + data) + assert sorted(results) == sorted(expected) + + +def test_compose_query_yields_one_solution_per_offending_value(): + """A value that violates two parts of the postcondition is one solution, + so a processor that maps every solution to a result (SHACL §5.3.2) reports + it once.""" + rule = _rule(_IF_MODE_M, {"parts": _inner(label={"equals_string": "l"}, depth={"maximum_value": 0})}) + (query,) = _sparql_queries(_parse_shacl(_compose_schema(rule)), EX_COMP.Thing) + data = rdflib.Graph().parse( + data=_COMP_PREFIXES + 'ex:x a ex:Thing ; ex:mode "m" ; ex:parts ex:p1 . ex:p1 ex:label "x" ; ex:depth 5 .', + format="turtle", + ) + solutions = [(row.path, row.value) for row in data.query(query, initBindings={"this": EX_COMP.x})] + assert solutions == [(EX_COMP.parts, EX_COMP.p1)] + + +@pytest.mark.parametrize( + "flag,violates", + [ + pytest.param(Literal("true", datatype=XSD.boolean, normalize=False), True, id="true"), + pytest.param(Literal("1", datatype=XSD.boolean, normalize=False), True, id="lexical-1"), + pytest.param(Literal("false", datatype=XSD.boolean, normalize=False), False, id="false"), + pytest.param(Literal("true"), False, id="string-true"), + ], +) +def test_compose_boolean_precondition_compared_by_value(flag, violates): + """``equals_string`` on a boolean precondition slot compares the boolean it denotes, as in the named patterns.""" + schema = _compose_schema(_rule({"flag": {"equals_string": "true"}}, _REQUIRE_NOTE)) + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.flag, flag)) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "kind,violates", + [ + pytest.param("ex:Special", False, id="meaning-iri"), + pytest.param('"Special"', True, id="permissible-value-text"), + pytest.param('"Plain"', True, id="other-value"), + ], +) +def test_compose_nested_postcondition_compares_enum_meaning(kind, violates): + """In a nested postcondition, an enum value with a ``meaning`` is compared as + that IRI, resolved on the range class, as ``rdflib_dumper`` writes it. The + generated JSON-LD context leaves the values of this enum, which has a value + without a meaning, as strings, so the expectations are explicit.""" + rule = _rule(_IF_MODE_M, {"part": _inner(kind={"equals_string": "Special"})}) + data = f'{_COMP_PREFIXES}ex:x a ex:Thing ; ex:mode "m" ; ex:part ex:p . ex:p ex:kind {kind} .' + _, focus_nodes = _validate_rules(_compose_schema(rule), data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +# The numeric datatypes of SPARQL 1.1 §17.1, . +_SPARQL_NUMERIC_DATATYPES = [ + *("integer", "decimal", "float", "double"), + *("nonPositiveInteger", "negativeInteger", "long", "int", "short", "byte"), + *("nonNegativeInteger", "unsignedLong", "unsignedInt", "unsignedShort", "unsignedByte", "positiveInteger"), +] + + +@pytest.mark.parametrize("datatype", _SPARQL_NUMERIC_DATATYPES) +def test_compose_bounds_on_every_sparql_numeric_datatype(datatype): + """Bounds are translated on a type with any numeric datatype of SPARQL 1.1 + §17.1 and compare its values numerically.""" + schema = json.loads(_compose_schema(_rule({"amount": {"minimum_value": -10, "maximum_value": 10}}, _REQUIRE_NOTE))) + base = datatype if datatype in ("decimal", "float", "double") else "integer" + schema["types"] = {"Amount": {"typeof": base, "uri": f"xsd:{datatype}"}} + schema["classes"]["Thing"]["attributes"]["amount"] = {"range": "Amount"} + sign = -1 if datatype in ("nonPositiveInteger", "negativeInteger") else 1 + for value, violates in ((5 * sign, True), (50 * sign, False)): + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.amount, Literal(str(value), datatype=XSD[datatype]))) + _, focus_nodes = _validate_rules(json.dumps(schema), data) + assert focus_nodes == ({EX_COMP.x} if violates else set()), value + + +@pytest.mark.parametrize( + "slot_range,translated", + [ + *(pytest.param(r, True, id=r) for r in ("integer", "decimal", "float", "double")), + pytest.param("Count", True, id="custom-nonNegativeInteger"), + *( + pytest.param(r, False, id=r) + for r in ("string", "date", "datetime", "time", "boolean", "uriorcurie", "Kind", "Part") + ), + pytest.param("Year", False, id="custom-gYear"), + pytest.param("Span", False, id="custom-duration"), + ], +) +def test_compose_bounds_only_on_numeric_ranges(caplog, slot_range, translated): + """Bounds compare numerically, so they are translated on a type with a + numeric datatype and skip the rule on any other range: SPARQL orders no + string, date, duration, boolean, IRI or node against a number (§17.3).""" + schema = json.loads(_compose_schema(_rule({"amount": {"minimum_value": 1}}, _REQUIRE_NOTE))) + schema["types"] = { + "Count": {"typeof": "integer", "uri": "xsd:nonNegativeInteger"}, + "Year": {"typeof": "string", "uri": "xsd:gYear"}, + "Span": {"typeof": "string", "uri": "xsd:duration"}, + } + schema["classes"]["Thing"]["attributes"]["amount"] = {"range": slot_range} + with caplog.at_level(logging.WARNING): + g = _parse_shacl(json.dumps(schema)) + assert len(_sparql_queries(g, EX_COMP.Thing)) == (1 if translated else 0) + skipped = "its minimum_value on slot 'amount' needs a type with a numeric datatype, and the slot's range is " + skipped += repr(slot_range) + assert any(skipped in rec.message for rec in caplog.records) != translated, caplog.text + + +@pytest.mark.parametrize( + "level,violates", + [ + pytest.param(Literal(15), True, id="integer"), + pytest.param(Literal("15"), False, id="string"), + pytest.param(EX_COMP.fifteen, False, id="iri"), + ], +) +def test_compose_bound_fails_on_a_value_that_is_not_a_number(level, violates): + """A value that is not a number fails a numeric bound, since comparing it + is a type error (SPARQL 1.1 §17.3); its datatype violation is reported + by the property shape, not by the rule.""" + schema = _compose_schema(_rule({"level": {"minimum_value": 10}}, _REQUIRE_NOTE)) + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.level, level)) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "rule,reason", + [ + pytest.param( + _rule({"level": {"equals_string": "3"}}, _REQUIRE_NOTE), + "whose range 'integer' is neither an enum nor a type with datatype xsd:string or xsd:boolean", + id="equals-string-on-integer", + ), + pytest.param( + _rule({"part": _inner(label={"maximum_value": 3})}, _REQUIRE_NOTE), + "its maximum_value on slot 'label' needs a type with a numeric datatype, and the slot's range is 'string'", + id="inner-bound-on-string", + ), + pytest.param( + _rule({"mode": {"pattern": "^m"}}, _REQUIRE_NOTE), + "its precondition on slot 'mode' uses pattern", + id="pre-op", + ), + pytest.param( + {"preconditions": {"slot_conditions": {}}, "postconditions": {"slot_conditions": _REQUIRE_NOTE}}, + "its preconditions constrain no slot", + id="empty-preconditions", + ), + pytest.param( + {"preconditions": {"slot_conditions": _IF_MODE_M}, "postconditions": {}}, + "its postconditions constrain no slot", + id="empty-postconditions", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"required": True, "pattern": "^n"}}), + "its postcondition on slot 'note' uses pattern, required", + id="post-op", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"required": False}}), + "its postcondition on slot 'note' constrains nothing", + id="post-not-required", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {}}, open_world=True), + "its postcondition on slot 'note' constrains nothing", + id="post-open-world-empty", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"value_presence": "UNCOMMITTED", "required": True}}), + "its postcondition on slot 'note' constrains nothing", + id="post-value-presence-overrides-required", + ), + pytest.param( + _rule(_IF_MODE_M, {**_REQUIRE_NOTE, "level": {"required": True}}), + "its postconditions constrain more than one slot", + id="two-postconditions", + ), + pytest.param( + _rule(_IF_MODE_M, {"parts": {"has_member": {"equals_string": "x"}}}), + "equals_string(_in) on slot 'parts', whose range 'Part' is neither an enum", + id="has-member-value-on-class-range", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {"pattern": "^1"}}}), + "its has_member on slot 'levels' uses pattern", + id="has-member-op", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {"required": True}}}), + "its has_member on slot 'levels' uses required", + id="has-member-presence", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {}}}), + "its has_member on slot 'levels' uses no operator", + id="has-member-empty", + ), + pytest.param( + _rule({"level": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE), + "its range_expression is on slot 'level', whose range is not a class", + id="range-expression-on-non-class", + ), + pytest.param( + _rule( + {"part": {"range_expression": {"any_of": [{"slot_conditions": {"depth": {"maximum_value": 0}}}]}}}, + _REQUIRE_NOTE, + ), + "its range_expression on slot 'part' uses any_of,", + id="range-expression-any-of", + ), + pytest.param( + _rule( + { + "part": { + "range_expression": { + **_DEPTH_AT_MOST_ZERO["range_expression"], + "none_of": [{"slot_conditions": {"label": {"equals_string": "x"}}}], + } + } + }, + _REQUIRE_NOTE, + ), + "its range_expression on slot 'part' uses none_of, slot_conditions", + id="range-expression-slot-conditions-and-none-of", + ), + pytest.param( + _rule({"part": {"range_expression": {"slot_conditions": {}}}}, _REQUIRE_NOTE), + "its range_expression on slot 'part' uses no operator", + id="range-expression-empty", + ), + pytest.param( + _rule({"part": _inner(depth={"pattern": "^1"})}, _REQUIRE_NOTE), + "its inner condition on slot 'depth' uses pattern", + id="inner-op", + ), + pytest.param( + _rule({"part": _inner(nope={"maximum_value": 0})}, _REQUIRE_NOTE), + "'nope', which is not a slot", + id="inner-unknown-slot", + ), + pytest.param( + _rule(_IF_MODE_M, {"site": _inner(id={"equals_string": "ex:s1"})}), + "its condition is on the identifier slot 'id'", + id="inner-identifier", + ), + ], +) +def test_compose_untranslatable_rule_skipped(caplog, rule, reason): + """The compositional fallback is exact too: any operator it does not translate skips the rule, with the reason.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_compose_schema(rule)) + assert _sparql_queries(g, EX_COMP.Thing) == [] + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1 and "skipped, because" in messages[0] and reason in messages[0], caplog.text + assert "in the shapes of" not in messages[0], "a problem with an inner slot belongs to the outer rule" + + +@pytest.mark.parametrize("bound", ['"abc"', "2020-01-01", "true", ".nan", ".inf", "1e20"]) +def test_compose_non_numeric_bound_skipped(caplog, bound): + """A ``minimum_value`` that is not a finite number (its metamodel range is + ``Anything``) skips the rule: interpolated, it would make the query + unparsable or compare as arithmetic. YAML 1.1 reads ``1e20``, which has + no dot, as a string.""" + marker = "__BOUND__" + schema = _compose_schema(_rule({"level": {"minimum_value": marker}}, _REQUIRE_NOTE)) + schema = schema.replace(f'"{marker}"', bound) # a raw YAML scalar + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + assert _sparql_queries(g, EX_COMP.Thing) == [] + assert any("is not a finite number" in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "schema,reason", + [ + pytest.param( + _compose_schema( + _rule({"part": _inner(kind={"equals_string": "Bogus"}), "mode": {"pattern": "^m"}}, _REQUIRE_NOTE) + ), + "its precondition on slot 'mode' uses pattern", + id="composed", + ), + pytest.param( + _single_rule_schema( + _rule({"levels": {"equals_string": "3"}}, {"levels": {"maximum_cardinality": 1}}), + {"levels": {"range": "integer", "multivalued": True}}, + ), + "whose range 'integer' is neither an enum", + id="exclusive-value", + ), + ], +) +def test_rule_skip_replaces_problems_noted_while_translating(caplog, schema, reason): + """A rule skipped part-way through its translation reports only why it was + skipped: a problem noted earlier (a value no enum permits, the reading of a + bare equals_string) describes a constraint that is not emitted.""" + with caplog.at_level(logging.WARNING): + _parse_shacl(schema) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1 and "skipped, because" in messages[0] and reason in messages[0], messages + + +def test_rule_skip_in_a_subclass_keeps_the_problems_of_other_shapes(caplog): + """A rule skipped for a subclass, whose ``slot_usage`` changes a slot, keeps + the problems noted where it is translated.""" + schema = json.loads( + _compose_schema(_rule({"kind": {"equals_string": "Bogus"}}, _REQUIRE_NOTE), slots=["kind"], subclass=True) + ) + schema["classes"]["SubThing"]["slot_usage"] = {"kind": {"range": "integer"}} + with caplog.at_level(logging.WARNING): + g = _parse_shacl(json.dumps(schema)) + assert len(_sparql_queries(g, EX_COMP.Thing)) == 1 and _sparql_queries(g, EX_COMP.SubThing) == [] + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 2, messages + assert any("'Bogus', which is not a permissible value" in m and "in the shapes of" not in m for m in messages) + assert any("skipped, because" in m and "(in the shapes of 'SubThing')" in m for m in messages) + + +@pytest.mark.parametrize( + "part_usage,thing_usage,expected_iris,unexpected_iris", + [ + pytest.param( + {"kind": {"slot_uri": "ex:partKind"}}, {}, [EX_COMP.partKind], [EX_COMP.kind], id="inner-slot-uri-override" + ), + pytest.param( + {"kind": {"slot_uri": "ex:partKind"}}, + {"kind": {"slot_uri": "ex:thingKind"}}, + [EX_COMP.partKind], + [EX_COMP.thingKind], + id="inner-slot-not-shadowed-by-outer", + ), + pytest.param( + {}, {"part": {"range": "SpecialPart"}}, [EX_COMP.VerySpecial], [EX_COMP.Special], id="container-narrowed" + ), + ], +) +def test_compose_inner_slot_resolved_on_range_class(part_usage, thing_usage, expected_iris, unexpected_iris): + """Inner slots of a nested expression resolve in the induced context of the + container slot's range class: their IRI and their enum values come from it.""" + rule = _rule({"part": _inner(kind={"equals_string": "Special"})}, _REQUIRE_NOTE) + schema = _compose_schema( + rule, part_usage=part_usage, thing_usage=thing_usage, slots=["kind"] if "kind" in thing_usage else None + ) + (query,) = _sparql_queries(_parse_shacl(schema), EX_COMP.Thing) + for iri in expected_iris: + assert f"<{iri}>" in query, query + for iri in unexpected_iris: + assert f"<{iri}>" not in query, query + + +@pytest.mark.parametrize( + "values,violates", + [ + pytest.param("", True, id="both-absent"), + pytest.param(' ; ex:mode "m"', False, id="one-present"), + pytest.param(' ; ex:mode "m" ; ex:level 1', False, id="both-present"), + ], +) +def test_compose_several_absent_preconditions_each_hold(values, violates): + """Preconditions are a conjunction, so ``value_presence: ABSENT`` on two + slots requires both to be absent. The JSON Schema generator merges them + into one ``not: {required: [...]}``, "not all present", so the + expectations are explicit.""" + rule = _rule({"mode": {"value_presence": "ABSENT"}, "level": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE) + _, focus_nodes = _validate_rules(_compose_schema(rule), f"{_COMP_PREFIXES}ex:x a ex:Thing{values} .") + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "inner,site,violates", + [ + pytest.param({"maximum_value": 0}, "ex:s1 ex:depth -1 .", True, id="referenced-node-satisfies"), + pytest.param({"maximum_value": 0}, "ex:s1 ex:depth 5 .", False, id="referenced-node-fails"), + pytest.param({"maximum_value": 0}, "", True, id="referenced-node-not-described"), + pytest.param({"maximum_value": 0, "required": True}, "", False, id="required-on-undescribed-node"), + ], +) +def test_compose_range_expression_on_a_reference(inner, site, violates): + """On a slot that references a node instead of inlining it, a nested + condition applies to the referenced node's triples in the data graph; a + node the graph does not describe has none. JSON has only the identifier + there, so the expectations are explicit.""" + rule = _rule({"site": _inner(depth=inner)}, _REQUIRE_NOTE) + data = f"{_COMP_PREFIXES}ex:x a ex:Thing ; ex:site ex:s1 . {site}" + _, focus_nodes = _validate_rules(_compose_schema(rule), data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +# --------------------------------------------------------------------------- +# pattern inside any_of branches +# --------------------------------------------------------------------------- + + +def test_any_of_with_pattern(input_path): + """Test that pattern constraints inside any_of branches emit sh:pattern. + + Exercises three cases: + 1. PatternOnlyBranch: any_of with a pattern-only branch (no range) + 2. RangeWithPattern: any_of with range + pattern on the same branch + 3. MixedBranches: combination of range-only, pattern-only, and range+pattern + """ + shacl = ShaclGenerator(input_path("shaclgen/any_of_pattern.yaml"), mergeimports=True).serialize() + g = rdflib.Graph() + g.parse(data=shacl) + + def get_or_branch_nodes(class_uri: str, slot_local: str) -> list[rdflib.BNode]: + """Return the list of BNodes inside sh:or for a given class property.""" + class_ref = URIRef(class_uri) + for prop_node in g.objects(class_ref, SH.property): + paths = list(g.objects(prop_node, SH.path)) + if any(slot_local in str(p) for p in paths): + for or_head in g.objects(prop_node, SH["or"]): + return list(Collection(g, or_head)) + return [] + + prefix = "https://w3id.org/linkml/examples/any_of_pattern/" + + # Case 1: PatternOnlyBranch — license slot has 3 branches: + # [enum sh:in], [sh:nodeKind sh:IRI], [sh:pattern "^LicenseRef-..."] + branches = get_or_branch_nodes(f"{prefix}PatternOnlyBranch", "license") + assert len(branches) == 3, f"Expected 3 branches, got {len(branches)}" + # Find the branch with sh:pattern + pattern_branches = [b for b in branches if list(g.objects(b, SH.pattern))] + assert len(pattern_branches) == 1, f"Expected 1 pattern branch, got {len(pattern_branches)}" + pattern_val = str(list(g.objects(pattern_branches[0], SH.pattern))[0]) + assert pattern_val == "^LicenseRef-[a-zA-Z0-9\\-\\.]+$" + # The pattern-only branch should NOT have sh:datatype or sh:class + assert list(g.objects(pattern_branches[0], SH.datatype)) == [] + assert list(g.objects(pattern_branches[0], SH["class"])) == [] + + # Case 2: RangeWithPattern — identifier slot has 2 branches: + # [sh:datatype xsd:string + sh:pattern "^[A-Z]{2}-[0-9]{4}$"], [sh:datatype xsd:integer] + branches = get_or_branch_nodes(f"{prefix}RangeWithPattern", "identifier") + assert len(branches) == 2, f"Expected 2 branches, got {len(branches)}" + # Find branch with both datatype and pattern + combo_branches = [b for b in branches if list(g.objects(b, SH.datatype)) and list(g.objects(b, SH.pattern))] + assert len(combo_branches) == 1, f"Expected 1 combo branch, got {len(combo_branches)}" + assert str(list(g.objects(combo_branches[0], SH.pattern))[0]) == "^[A-Z]{2}-[0-9]{4}$" + # The other branch (integer) should NOT have sh:pattern + int_branches = [b for b in branches if b not in combo_branches] + assert list(g.objects(int_branches[0], SH.pattern)) == [] + + # Case 3: MixedBranches — code slot has 3 branches: + # [sh:datatype xsd:integer], [sh:pattern "^CUSTOM-.*$"], [sh:datatype xsd:string + sh:pattern "^STD-[0-9]+$"] + branches = get_or_branch_nodes(f"{prefix}MixedBranches", "code") + assert len(branches) == 3, f"Expected 3 branches, got {len(branches)}" + # Exactly 2 branches should have sh:pattern + pattern_branches = [b for b in branches if list(g.objects(b, SH.pattern))] + assert len(pattern_branches) == 2, f"Expected 2 pattern branches, got {len(pattern_branches)}" + # Collect the patterns + patterns = sorted(str(list(g.objects(b, SH.pattern))[0]) for b in pattern_branches) + assert patterns == ["^CUSTOM-.*$", "^STD-[0-9]+$"] + # The integer-only branch should have no pattern + no_pattern = [b for b in branches if not list(g.objects(b, SH.pattern))] + assert len(no_pattern) == 1 + assert list(g.objects(no_pattern[0], SH.datatype)) == [URIRef("http://www.w3.org/2001/XMLSchema#integer")] + + +def test_any_of_with_pattern_pyshacl_end_to_end(input_path): + """End-to-end: pyshacl accepts values matching an ``any_of`` pattern branch and rejects others. + + This is the behavioural regression guard for the fix. Without ``sh:pattern`` on the + branch node, a pattern-only branch serialises as an empty shape ``[ ]``, which every + value node trivially satisfies — so ``sh:or`` would accept *anything* and the + non-conforming assertions below would fail. + """ + import pyshacl + + shacl_ttl = ShaclGenerator(input_path("shaclgen/any_of_pattern.yaml"), mergeimports=True).serialize() + + def conforms(data_ttl: str) -> tuple[bool, str]: + ok, _, text = pyshacl.validate( + data_graph=data_ttl, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + ) + return ok, text + + prefixes = """ + @prefix ex: . + @prefix xsd: . + """ + + # Case 1: pattern-only branch. "MIT" satisfies the enum branch, an IRI satisfies the + # uri branch, and "LicenseRef-..." may only satisfy the pattern-only branch. + for value in ('"MIT"', "", '"LicenseRef-My-Custom.1"'): + ok, text = conforms(f"{prefixes}\nex:l1 a ex:PatternOnlyBranch ; ex:license {value} .") + assert ok, f"license {value} should conform:\n{text}" + # No branch matches: not an enum member, not an IRI, and does not match the pattern. + ok, _ = conforms(f'{prefixes}\nex:l2 a ex:PatternOnlyBranch ; ex:license "NotALicenseRef" .') + assert not ok, "A value matching no any_of branch must be rejected" + + # Case 2: range + pattern on the same branch — both must hold for that branch. + ok, text = conforms(f'{prefixes}\nex:i1 a ex:RangeWithPattern ; ex:identifier "AB-1234" .') + assert ok, f"identifier 'AB-1234' should conform:\n{text}" + ok, text = conforms(f'{prefixes}\nex:i2 a ex:RangeWithPattern ; ex:identifier "42"^^xsd:integer .') + assert ok, f"identifier 42 should conform via the integer branch:\n{text}" + ok, _ = conforms(f'{prefixes}\nex:i3 a ex:RangeWithPattern ; ex:identifier "ab-1234" .') + assert not ok, "A string violating the branch pattern must be rejected" + + # Case 3: mixed branches — each branch accepts only its own values. + for value in ('"7"^^xsd:integer', '"CUSTOM-anything"', '"STD-42"'): + ok, text = conforms(f"{prefixes}\nex:c1 a ex:MixedBranches ; ex:code {value} .") + assert ok, f"code {value} should conform:\n{text}" + ok, _ = conforms(f'{prefixes}\nex:c2 a ex:MixedBranches ; ex:code "STD-xyz" .') + assert not ok, "A value matching no any_of branch must be rejected" + + +# --------------------------------------------------------------------------- +# Class expressions → sh:or / sh:and / sh:xone / sh:not +# --------------------------------------------------------------------------- + +EX_CE = rdflib.Namespace("https://example.org/class-expressions/") + +_CLASS_EXPRESSION_HEADER = """ +id: https://example.org/class-expressions +name: class_expressions +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/class-expressions/ +imports: + - linkml:types +default_prefix: ex +default_range: string +""" + +_REFERENCE_SYSTEM_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + codeEPSG: + range: integer + coordinateSystemName: {{}} +classes: + ReferenceSystem: + class_uri: ex:ReferenceSystem + slots: [codeEPSG, coordinateSystemName] + {operator}: + - slot_conditions: + codeEPSG: + required: true + - slot_conditions: + coordinateSystemName: + required: true +""" +) + +_LOGICAL_PREDICATES = (SH["or"], SH["and"], SH.xone, SH["not"]) + + +def _list_members(g, shape, predicate): + """Members of the single SHACL list that *shape* has for *predicate*.""" + lists = list(g.objects(shape, predicate)) + assert len(lists) == 1, f"expected one {predicate} list on {shape}, got {len(lists)}" + return list(Collection(g, lists[0])) + + +def _condition(g, member, path): + """The property shape for *path* inside the member shape *member*.""" + shapes = [p for p in g.objects(member, SH.property) if (p, SH.path, path) in g] + assert len(shapes) == 1, f"expected one condition on {path}, got {len(shapes)}" + return shapes[0] + + +def _conforms(shacl_ttl: str, data_ttl: str) -> bool: + """Validate *data_ttl*; meta_shacl makes pyshacl fail on an ill-formed shapes graph.""" + import pyshacl + + conforms, _, _ = pyshacl.validate( + data_graph=data_ttl, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + meta_shacl=True, + ) + return conforms + + +@pytest.mark.parametrize( + "operator,predicate", + [("any_of", SH["or"]), ("all_of", SH["and"]), ("exactly_one_of", SH.xone)], +) +def test_class_expression_list_operator_generates_logical_constraint(operator, predicate): + """any_of, all_of and exactly_one_of become sh:or, sh:and and sh:xone over the member shapes.""" + g = _parse_shacl(_REFERENCE_SYSTEM_SCHEMA.format(operator=operator)) + + members = _list_members(g, EX_CE.ReferenceSystem, predicate) + assert len(members) == 2 + for member, path in zip(members, (EX_CE.codeEPSG, EX_CE.coordinateSystemName)): + condition = _condition(g, member, path) + assert (condition, SH.minCount, Literal(1)) in g + assert (member, SH.targetClass, None) not in g + others = set(_LOGICAL_PREDICATES) - {predicate} + assert not any((EX_CE.ReferenceSystem, p, None) in g for p in others) + + +def test_class_expression_none_of_generates_one_sh_not_per_member(): + """none_of becomes one sh:not per member; the negated constraints all apply (SHACL §2.1.1).""" + g = _parse_shacl(_REFERENCE_SYSTEM_SCHEMA.format(operator="none_of")) + + negated = list(g.objects(EX_CE.ReferenceSystem, SH["not"])) + assert len(negated) == 2 + paths = {path for member in negated for p in g.objects(member, SH.property) for path in g.objects(p, SH.path)} + assert paths == {EX_CE.codeEPSG, EX_CE.coordinateSystemName} + + +@pytest.mark.parametrize( + "operator,properties,expected", + [ + ("any_of", 'ex:codeEPSG 4326 ; ex:coordinateSystemName "WGS 84"', True), + ("any_of", "ex:codeEPSG 4326", True), + ("any_of", "", False), + ("exactly_one_of", "ex:codeEPSG 4326", True), + ("exactly_one_of", 'ex:codeEPSG 4326 ; ex:coordinateSystemName "WGS 84"', False), + ("exactly_one_of", "", False), + ("all_of", 'ex:codeEPSG 4326 ; ex:coordinateSystemName "WGS 84"', True), + ("all_of", "ex:codeEPSG 4326", False), + ("none_of", "", True), + ("none_of", "ex:codeEPSG 4326", False), + ], +) +def test_class_expression_pyshacl_end_to_end(operator, properties, expected): + """End-to-end: each operator admits exactly the instances the metamodel says it holds for.""" + shacl_ttl = ShaclGenerator(_REFERENCE_SYSTEM_SCHEMA.format(operator=operator), mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:rs a ex:ReferenceSystem {";" if properties else ""} {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +_FORMAT_PROFILES_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + fileFormat: {} + formatType: {} + version: {} + hasChannel: + range: Channel + multivalued: true + inlined: true +classes: + Channel: + class_uri: ex:Channel + Format: + class_uri: ex:Format + slots: [fileFormat, formatType, version, hasChannel] + any_of: + - description: A single-channel trace. + slot_conditions: + fileFormat: + equals_string_in: [OSI, TXTH] + formatType: + required: true + version: + required: true + - description: A multi-channel container. + slot_conditions: + fileFormat: + required: true + equals_string: MCAP + hasChannel: + required: true +""" +) + + +@pytest.mark.parametrize( + "properties,expected", + [ + ('ex:fileFormat "OSI" ; ex:formatType "SensorView" ; ex:version "3.7.0"', True), + ('ex:fileFormat "MCAP" ; ex:hasChannel ex:ch', True), + ('ex:fileFormat "MCAP" ; ex:formatType "SensorView" ; ex:version "3.7.0"', False), + ('ex:fileFormat "OSI" ; ex:formatType "SensorView" ; ex:hasChannel ex:ch', False), + ], +) +def test_class_expression_any_of_profiles_pyshacl_end_to_end(properties, expected): + """End-to-end: a class-level any_of selects between two complete profiles of a class.""" + shacl_ttl = ShaclGenerator(_FORMAT_PROFILES_SCHEMA, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:ch a ex:Channel . + ex:f a ex:Format ; {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +def test_class_expression_member_metadata(): + """A member's title and description annotate its shape, as they do for a class's NodeShape.""" + g = _parse_shacl(_FORMAT_PROFILES_SCHEMA) + + comments = {str(c) for m in _list_members(g, EX_CE.Format, SH["or"]) for c in g.objects(m, RDFS.comment)} + assert comments == {"A single-channel trace.", "A multi-channel container."} + + +_CONDITIONS_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +enums: + ColourEnum: + permissible_values: + red: {} + blue: {} +slots: + label: {} + size: + range: integer + count: + multivalued: true + exact: + multivalued: true + colour: {} + target: {} + note: {} +classes: + Target: + class_uri: ex:Target + Thing: + class_uri: ex:Thing + slots: [label, size, count, exact, colour, target, note] + all_of: + - title: every condition + slot_conditions: + label: + description: Starts upper case. + required: true + pattern: "^[A-Z]" + equals_string_in: [Alpha, Beta] + size: + minimum_value: 1 + maximum_value: 10 + equals_number: 5 + count: + required: true + minimum_cardinality: 2 + maximum_cardinality: 4 + exact: + exact_cardinality: 3 + colour: + range: ColourEnum + target: + value_presence: PRESENT + range: Target + note: + value_presence: ABSENT +""" +) + + +def test_class_expression_slot_condition_fields(): + """Each supported slot-condition field maps to the SHACL constraint the slot loop uses for it.""" + g = _parse_shacl(_CONDITIONS_SCHEMA) + (member,) = _list_members(g, EX_CE.Thing, SH["and"]) + assert (member, RDFS.label, Literal("every condition")) in g + + def values(path, predicate): + return set(g.objects(_condition(g, member, path), predicate)) + + def in_list(path): + (node,) = values(path, SH["in"]) + return list(Collection(g, node)) + + assert values(EX_CE.label, SH.minCount) == {Literal(1)} + assert values(EX_CE.label, SH.pattern) == {Literal("^[A-Z]")} + assert values(EX_CE.label, SH.description) == {Literal("Starts upper case.")} + assert in_list(EX_CE.label) == [Literal("Alpha"), Literal("Beta")] + + # equals_number is a value comparison; SHACL allows one sh:minInclusive and one + # sh:maxInclusive per shape, so next to the bounds it moves into an sh:and member + assert values(EX_CE.size, SH.minInclusive) == {Literal(1)} + assert values(EX_CE.size, SH.maxInclusive) == {Literal(10)} + (and_node,) = values(EX_CE.size, SH["and"]) + repeated = {(p, o) for m in Collection(g, and_node) for p, o in g.predicate_objects(m)} + assert repeated == {(SH.minInclusive, Literal(5)), (SH.maxInclusive, Literal(5))} + assert values(EX_CE.size, SH["in"]) == set() + assert values(EX_CE.size, SH.minCount) == set() + assert values(EX_CE.size, SH.hasValue) == set() + + # required and a cardinality give one sh:minCount, the stricter of the two + assert values(EX_CE["count"], SH.minCount) == {Literal(2)} + assert values(EX_CE["count"], SH.maxCount) == {Literal(4)} + assert values(EX_CE.exact, SH.minCount) == {Literal(3)} + assert values(EX_CE.exact, SH.maxCount) == {Literal(3)} + + assert in_list(EX_CE.colour) == [Literal("red"), Literal("blue")] + + assert values(EX_CE.target, SH.minCount) == {Literal(1)} + assert values(EX_CE.target, SH["class"]) == {EX_CE.Target} + assert values(EX_CE.target, SH.nodeKind) == {SH.BlankNodeOrIRI} + + assert values(EX_CE.note, SH.maxCount) == {Literal(0)} + assert values(EX_CE.note, SH.minCount) == set() + + +@pytest.mark.parametrize( + "condition,properties,expected", + [ + # outside none_of a value constraint says nothing about presence + ("any_of", "", True), + ("any_of", 'ex:label "b"', False), + # inside none_of it requires the slot, so an absent slot is not rejected + ("none_of", "", True), + ("none_of", 'ex:label "A"', False), + ("none_of", 'ex:label "B"', True), + ], +) +def test_class_expression_presence_semantics(condition, properties, expected): + """A condition holds vacuously for an absent slot, except under none_of, as in the JSON Schema generator.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + f""" +slots: + label: {{}} +classes: + Thing: + class_uri: ex:Thing + slots: [label] + {condition}: + - slot_conditions: + label: + {"pattern: '^[A-Z]'" if condition == "any_of" else "equals_string: A"} +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:t a ex:Thing {";" if properties else ""} {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +def test_class_expression_nested_and_is_a(): + """Nested expressions recurse into the member shape; is_a gives sh:class.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + a: {} + b: {} + c: {} +classes: + Marker: + class_uri: ex:Marker + Thing: + class_uri: ex:Thing + slots: [a, b, c] + any_of: + - all_of: + - slot_conditions: + a: + required: true + - slot_conditions: + b: + required: true + - is_a: Marker + slot_conditions: + c: + required: true +""" + ) + # open shapes: the instance is also a Marker, whose own shape declares no slots + shacl_ttl = ShaclGenerator(schema, mergeimports=False, closed=False).serialize() + g = rdflib.Graph().parse(data=shacl_ttl) + first, second = _list_members(g, EX_CE.Thing, SH["or"]) + assert len(_list_members(g, first, SH["and"])) == 2 + assert (second, SH["class"], EX_CE.Marker) in g + + prefix = "@prefix ex: ." + assert _conforms(shacl_ttl, f'{prefix} ex:t a ex:Thing ; ex:a "1" ; ex:b "2" .') + assert not _conforms(shacl_ttl, f'{prefix} ex:t a ex:Thing ; ex:a "1" .') + assert _conforms(shacl_ttl, f'{prefix} ex:t a ex:Thing, ex:Marker ; ex:c "3" .') + assert not _conforms(shacl_ttl, f'{prefix} ex:t a ex:Thing ; ex:c "3" .') + + +_INHERITED_CLASS_EXPRESSION_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + a: {} + b: {} +classes: + Marker: + class_uri: ex:Marker + mixin: true + none_of: + - slot_conditions: + a: + equals_string: forbidden + Parent: + class_uri: ex:Parent + slots: [a, b] + any_of: + - slot_conditions: + a: + required: true + - slot_conditions: + b: + required: true + Child: + class_uri: ex:Child + is_a: Parent + mixins: [Marker] + GrandChild: + class_uri: ex:GrandChild + is_a: Child +""" +) +_CE_PREFIXES = ( + "@prefix ex: .\n@prefix rdfs: .\n" +) + + +@pytest.mark.parametrize("subclass_axioms", [False, True], ids=["typed-only", "with-rdfs-subClassOf"]) +@pytest.mark.parametrize("class_name", ["Parent", "Child", "GrandChild"]) +@pytest.mark.parametrize( + "values,conforms", + [ + pytest.param(' ; ex:a "1"', True, id="a"), + pytest.param(' ; ex:b "2"', True, id="b"), + pytest.param("", False, id="neither"), + ], +) +def test_class_expression_applies_to_subclass_instances(subclass_axioms, class_name, values, conforms): + """A class expression constrains every instance of its class, so the shapes + of its subclasses carry it too. A node typed only with a subclass, as + rdflib_dumper and the JSON-LD context produce it, is checked without an + rdfs:subClassOf triple in the data; with one, the verdict is the same.""" + shacl_ttl = ShaclGenerator(_INHERITED_CLASS_EXPRESSION_SCHEMA, mergeimports=False, closed=False).serialize() + axioms = ( + "ex:Child rdfs:subClassOf ex:Parent . ex:GrandChild rdfs:subClassOf ex:Child .\n" if subclass_axioms else "" + ) + assert _conforms(shacl_ttl, f"{_CE_PREFIXES}{axioms}ex:x a ex:{class_name}{values} .") == conforms + + +@pytest.mark.parametrize("class_name,conforms", [("Parent", True), ("Child", False), ("GrandChild", False)]) +def test_class_expression_of_a_mixin_applies_to_the_classes_using_it(class_name, conforms): + """A mixin's class expression constrains the classes that use the mixin and + their subclasses, and no other class.""" + shacl_ttl = ShaclGenerator(_INHERITED_CLASS_EXPRESSION_SCHEMA, mergeimports=False, closed=False).serialize() + assert _conforms(shacl_ttl, f'{_CE_PREFIXES}ex:x a ex:{class_name} ; ex:a "forbidden" .') == conforms + + +def test_inherited_class_expression_follows_the_subclass_slot_usage(): + """An inherited class expression is translated in the subclass's context, so + its conditions use the subclass's slot_usage, as the subclass's own + property shapes do.""" + schema = yaml.safe_load(_INHERITED_CLASS_EXPRESSION_SCHEMA) + schema["classes"]["Child"]["slot_usage"] = {"a": {"slot_uri": "ex:childA"}} + g = rdflib.Graph().parse(data=ShaclGenerator(json.dumps(schema), mergeimports=False).serialize()) + for shape, path in ((EX_CE.Parent, EX_CE.a), (EX_CE.Child, EX_CE.childA), (EX_CE.GrandChild, EX_CE.childA)): + (or_list,) = g.objects(shape, SH["or"]) + paths = { + p + for member in Collection(g, or_list) + for prop in g.objects(member, SH.property) + for p in g.objects(prop, SH.path) + } + assert paths == {path, EX_CE.b}, (shape, paths) + + +def test_classes_sharing_a_class_uri_carry_an_inherited_expression_once(): + """Classes with the same class_uri share one shape, which carries an + expression they inherit once.""" + schema = yaml.safe_load(_INHERITED_CLASS_EXPRESSION_SCHEMA) + schema["classes"]["Child"]["class_uri"] = "ex:Shared" + schema["classes"]["GrandChild"]["class_uri"] = "ex:Shared" + g = rdflib.Graph().parse(data=ShaclGenerator(json.dumps(schema), mergeimports=False).serialize()) + assert len(list(g.objects(EX_CE.Shared, SH["or"]))) == 1 + assert len(list(g.objects(EX_CE.Shared, SH["not"]))) == 1 + + +@pytest.mark.parametrize( + "child_usage,expected_shapes,warning", + [ + pytest.param( + None, + {EX_CE.Parent: False, EX_CE.Child: False, EX_CE.GrandChild: False}, + "Class 'Parent': any_of is not translated to SHACL, because it uses 'all_members' in the condition on " + "slot 'a' (in the shapes of 'Parent', 'Child', 'GrandChild').", + id="untranslatable-everywhere", + ), + pytest.param( + {"a": {"range": "integer"}}, + {EX_CE.Parent: True, EX_CE.Child: False, EX_CE.GrandChild: False}, + "Class 'Parent': any_of is not translated to SHACL, because it uses equals_string in the condition on " + "slot 'a', whose range 'integer' does not hold strings (in the shapes of 'Child', 'GrandChild').", + id="untranslatable-in-subclasses", + ), + ], +) +def test_untranslatable_inherited_class_expression_warned_once(caplog, child_usage, expected_shapes, warning): + """An inherited expression that cannot be translated is skipped in the + shapes where it cannot, and reported once, naming them.""" + schema = yaml.safe_load(_INHERITED_CLASS_EXPRESSION_SCHEMA) + schema["classes"]["Parent"]["any_of"] = ( + [{"slot_conditions": {"a": {"all_members": {"equals_string": "x"}}}}] + if child_usage is None + else [{"slot_conditions": {"a": {"equals_string": "x"}}}] + ) + if child_usage is not None: + schema["classes"]["Child"]["slot_usage"] = child_usage + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = rdflib.Graph().parse(data=ShaclGenerator(json.dumps(schema), mergeimports=False).serialize()) + for shape, translated in expected_shapes.items(): + assert ((shape, SH["or"], None) in g) == translated, shape + messages = [rec.message for rec in caplog.records if "any_of is not translated" in rec.message] + assert messages == [warning] + + +def test_class_expression_untranslatable_operator_skipped_with_warning(caplog): + """An operator with an untranslatable member is skipped whole, with a warning; the others are kept.""" + import logging + + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + tags: + multivalued: true + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [tags, a] + any_of: + - slot_conditions: + tags: + all_members: + equals_string: x + - slot_conditions: + a: + required: true + none_of: + - slot_conditions: + a: + equals_string: forbidden +""" + ) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = _parse_shacl(schema) + + assert (EX_CE.Thing, SH["or"], None) not in g + assert any( + "any_of" in rec.message and "all_members" in rec.message and "tags" in rec.message for rec in caplog.records + ) + # the translatable none_of is kept, and enforced + shacl_ttl = g.serialize(format="turtle") + assert not _conforms(shacl_ttl, f'{_CE_PREFIXES}ex:t a ex:Thing ; ex:a "forbidden" .') + assert _conforms(shacl_ttl, f'{_CE_PREFIXES}ex:t a ex:Thing ; ex:a "ok" .') + + +def test_class_expression_condition_on_identifier_skipped_with_warning(caplog): + """An identifier is the node's IRI, not a property arc, so a condition on it is not translated.""" + import logging + + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + id: + identifier: true + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [id, a] + exactly_one_of: + - slot_conditions: + id: + pattern: "^ex:" + - slot_conditions: + a: + required: true +""" + ) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = _parse_shacl(schema) + + assert (EX_CE.Thing, SH.xone, None) not in g + assert any("exactly_one_of" in rec.message and "identifier" in rec.message for rec in caplog.records) + + +def test_class_expression_absent_leaves_node_shapes_unchanged(): + """Without class-level expressions no node shape gets a logical constraint; slot-level any_of is unaffected.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + value: + any_of: + - range: integer + - range: string +classes: + Thing: + class_uri: ex:Thing + slots: [value] +""" + ) + g = _parse_shacl(schema) + + for shape in g.subjects(SH.targetClass, None): + assert not any((shape, p, None) in g for p in _LOGICAL_PREDICATES) + (value_shape,) = [p for p in g.objects(EX_CE.Thing, SH.property) if (p, SH.path, EX_CE.value) in g] + assert (value_shape, SH["or"], None) in g + + +def test_class_expression_condition_path_is_the_slot_induced_for_the_class(): + """A condition's sh:path is the one the class's own property shape uses, slot_usage and attributes included.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + code: + slot_uri: ex:baseCode + exact mappings: + slot_uri: ex:exactMatch +classes: + Thing: + class_uri: ex:Thing + slots: [code, exact mappings] + slot_usage: + code: + slot_uri: ex:thingCode + attributes: + loc: + slot_uri: ex:thingLoc + any_of: + - slot_conditions: + code: + required: true + - slot_conditions: + exact_mappings: + required: true + - slot_conditions: + loc: + required: true + Other: + class_uri: ex:Other + attributes: + loc: + slot_uri: ex:otherLoc +""" + ) + g = _parse_shacl(schema) + + class_paths = {path for p in g.objects(EX_CE.Thing, SH.property) for path in g.objects(p, SH.path)} + condition_paths = [ + path + for member in _list_members(g, EX_CE.Thing, SH["or"]) + for p in g.objects(member, SH.property) + for path in g.objects(p, SH.path) + ] + assert condition_paths and set(condition_paths) <= class_paths + assert set(condition_paths) == {EX_CE.thingCode, EX_CE.exactMatch, EX_CE.thingLoc} + + +_PRESENCE_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +enums: + Color: + permissible_values: + red: {} + green: {} +slots: + label: {} + note: {} + tags: + multivalued: true +classes: + Thing: + class_uri: ex:Thing + tree_root: true + slots: [label, note, tags] +""" +) + + +def _class_expression_verdicts(expression: dict, obj: dict) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for ``Thing`` with + the class-level *expression* accept *obj*. For SHACL, *obj* is loaded + through the generated JSON-LD context.""" + import jsonschema + import pyshacl + + from linkml.generators.jsonldcontextgen import ContextGenerator + from linkml.generators.jsonschemagen import JsonSchemaGenerator + + schema = yaml.safe_load(_PRESENCE_SCHEMA) + schema["classes"]["Thing"].update(expression) + schema = yaml.safe_dump(schema) + json_schema = json.loads(JsonSchemaGenerator(schema, top_class="Thing").serialize()) + context = json.loads(ContextGenerator(schema).serialize())["@context"] + data = rdflib.Graph().parse(data=json.dumps({"@context": context, "@type": "Thing", **obj}), format="json-ld") + shapes = rdflib.Graph().parse(data=ShaclGenerator(schema, mergeimports=False, closed=False).serialize()) + conforms, _, _ = pyshacl.validate(data, shacl_graph=shapes, meta_shacl=True) + return jsonschema.Draft7Validator(json_schema).is_valid(obj), conforms + + +def _is_a(value: str) -> dict: + """A class expression requiring ``label`` to equal *value*.""" + return {"slot_conditions": {"label": {"equals_string": value}}} + + +def _tags(**condition) -> dict: + """A class expression with *condition* on the multivalued ``tags``.""" + return {"slot_conditions": {"tags": condition}} + + +_LABEL_A_OR_NOTE_B = [_is_a("A"), {"slot_conditions": {"note": {"equals_string": "B"}}}] + + +@pytest.mark.parametrize( + "expression,obj,valid", + [ + # a condition is unknown for an absent slot, and only a definitely false expression is a violation + pytest.param({"any_of": [_is_a("A")]}, {}, True, id="any_of-absent"), + pytest.param({"any_of": [_is_a("A")]}, {"label": "Z"}, False, id="any_of-other"), + pytest.param({"any_of": _LABEL_A_OR_NOTE_B}, {"label": "Z"}, True, id="any_of-one-false-one-unknown"), + pytest.param({"none_of": [_is_a("A")]}, {}, True, id="none_of-absent"), + pytest.param({"none_of": [_is_a("A")]}, {"label": "A"}, False, id="none_of-match"), + pytest.param({"none_of": [_is_a("A")]}, {"label": "B"}, True, id="none_of-other"), + pytest.param({"none_of": [{"any_of": [_is_a("A"), _is_a("B")]}]}, {}, True, id="nested-in-none_of-absent"), + pytest.param( + {"none_of": [{"any_of": [_is_a("A"), _is_a("B")]}]}, {"label": "B"}, False, id="nested-in-none_of" + ), + pytest.param({"none_of": [{"any_of": [_is_a("A"), _is_a("B")]}]}, {"label": "C"}, True, id="nested-other"), + # the reading composes: wrapping in a one-member all_of, or negating twice, changes nothing + pytest.param({"all_of": [{"none_of": [_is_a("A")]}]}, {}, True, id="none_of-in-all_of-absent"), + pytest.param({"all_of": [{"none_of": [_is_a("A")]}]}, {"label": "A"}, False, id="none_of-in-all_of-match"), + pytest.param({"none_of": [{"none_of": [_is_a("A")]}]}, {}, True, id="double-negation-absent"), + pytest.param({"none_of": [{"none_of": [_is_a("A")]}]}, {"label": "A"}, True, id="double-negation-match"), + pytest.param({"none_of": [{"none_of": [_is_a("A")]}]}, {"label": "B"}, False, id="double-negation-other"), + # stated presence is definite; required: false only restates the default + pytest.param( + {"none_of": [{"slot_conditions": {"label": {"required": False, "equals_string": "A"}}}]}, + {}, + True, + id="restated-default", + ), + pytest.param( + {"none_of": [{"slot_conditions": {"label": {"value_presence": "ABSENT", "equals_string": "A"}}}]}, + {}, + False, + id="stated-absent", + ), + pytest.param( + {"none_of": [{"slot_conditions": {"label": {"value_presence": "ABSENT", "equals_string": "A"}}}]}, + {"label": "A"}, + True, + id="stated-absent-present", + ), + pytest.param( + {"all_of": [{"slot_conditions": {"note": {"required": True, "value_presence": "ABSENT"}}}]}, + {}, + True, + id="value_presence-over-required", + ), + pytest.param( + {"all_of": [{"slot_conditions": {"note": {"required": True, "value_presence": "ABSENT"}}}]}, + {"note": "x"}, + False, + id="value_presence-over-required-present", + ), + # an empty condition is unknown for an absent slot, and definitely true for a present one + pytest.param({"none_of": [{"slot_conditions": {"label": {}}}]}, {}, True, id="empty-condition-absent"), + pytest.param({"none_of": [{"slot_conditions": {"label": {}}}]}, {"label": "x"}, False, id="empty-condition"), + # exactly_one_of holds when exactly one member is definitely true + pytest.param({"exactly_one_of": _LABEL_A_OR_NOTE_B}, {}, False, id="exactly_one_of-none-known"), + pytest.param({"exactly_one_of": _LABEL_A_OR_NOTE_B}, {"label": "A"}, True, id="exactly_one_of-first"), + pytest.param({"exactly_one_of": _LABEL_A_OR_NOTE_B}, {"label": "Z"}, False, id="exactly_one_of-none-true"), + pytest.param( + {"exactly_one_of": _LABEL_A_OR_NOTE_B}, {"label": "A", "note": "X"}, True, id="exactly_one_of-one-of-two" + ), + pytest.param( + {"exactly_one_of": _LABEL_A_OR_NOTE_B}, {"label": "A", "note": "B"}, False, id="exactly_one_of-both" + ), + # cardinalities count the values of a list, of which an absent slot has none + pytest.param({"all_of": [_tags(minimum_cardinality=2)]}, {"tags": ["a"]}, False, id="minimum-cardinality"), + pytest.param({"all_of": [_tags(minimum_cardinality=2)]}, {"tags": ["a", "b"]}, True, id="minimum-met"), + pytest.param({"all_of": [_tags(minimum_cardinality=2)]}, {}, False, id="minimum-absent"), + pytest.param({"all_of": [_tags(maximum_cardinality=1)]}, {"tags": ["a", "b"]}, False, id="maximum-cardinality"), + pytest.param({"none_of": [_tags(maximum_cardinality=1)]}, {}, True, id="none_of-maximum-absent"), + pytest.param({"none_of": [_tags(maximum_cardinality=1)]}, {"tags": ["a"]}, False, id="none_of-maximum"), + pytest.param( + {"none_of": [_tags(maximum_cardinality=1)]}, {"tags": ["a", "b"]}, True, id="none_of-maximum-over" + ), + pytest.param({"any_of": [_tags(maximum_cardinality=0), _is_a("A")]}, {}, True, id="maximum-zero-absent"), + # a single-valued slot holds at most one value + pytest.param( + {"all_of": [{"slot_conditions": {"label": {"minimum_cardinality": 2}}}]}, + {"label": "x"}, + False, + id="single-valued-minimum-two", + ), + # a range in a condition narrows the slot's values + pytest.param( + {"all_of": [{"slot_conditions": {"label": {"range": "Color"}}}]}, {"label": "red"}, True, id="range" + ), + pytest.param( + {"all_of": [{"slot_conditions": {"label": {"range": "Color"}}}]}, {"label": "blue"}, False, id="range-other" + ), + pytest.param({"all_of": [{"slot_conditions": {"label": {"range": "Color"}}}]}, {}, True, id="range-absent"), + pytest.param( + {"none_of": [{"slot_conditions": {"label": {"range": "Color"}}}]}, + {"label": "red"}, + False, + id="none_of-range", + ), + ], +) +def test_class_expression_absent_slot_semantics(expression, obj, valid): + """The specification doesn't say what a slot condition means for an absent + slot. As with an SQL CHECK constraint, an expression is violated only when + it is definitely false: a condition that doesn't state presence is unknown + for an absent slot. exactly_one_of holds when exactly one member is + definitely true. The JSON Schema and SHACL generators decide alike.""" + assert _class_expression_verdicts(expression, obj) == (valid, valid) + + +@pytest.mark.parametrize( + "condition,tags,conforms", + [ + # a bound an absent slot satisfies and a present one may too leaves absence open + pytest.param({"maximum_cardinality": 1}, [], True, id="at-most-one-absent"), + pytest.param({"maximum_cardinality": 1}, ["a"], False, id="at-most-one"), + pytest.param({"maximum_cardinality": 1}, ["a", "b"], True, id="more-than-one"), + # a maximum of 0 decides that the slot is absent + pytest.param({"maximum_cardinality": 0, "pattern": "^A"}, [], False, id="at-most-zero-absent"), + pytest.param({"maximum_cardinality": 0, "pattern": "^A"}, ["A"], True, id="at-most-zero-present"), + ], +) +def test_class_expression_none_of_cardinality(condition, tags, conforms): + """Within none_of, a condition is definitely true or false for an absent + slot only when it decides presence, such as a maximum cardinality of 0; + otherwise it requires the slot in its "definitely true" form.""" + schema = yaml.safe_load(_PRESENCE_SCHEMA) + schema["classes"]["Thing"]["none_of"] = [{"slot_conditions": {"tags": condition}}] + shacl_ttl = ShaclGenerator(yaml.safe_dump(schema), mergeimports=False, closed=False).serialize() + values = "" if not tags else " ; ex:tags " + ", ".join(f'"{t}"' for t in tags) + assert _conforms(shacl_ttl, f"{_CE_PREFIXES}ex:x a ex:Thing{values} .") == conforms + + +@pytest.mark.parametrize( + "condition,properties,expected", + [ + # a cardinality is taken literally inside none_of: "not at most zero values" means present + ("maximum_cardinality: 0", "", False), + ("maximum_cardinality: 0", 'ex:tag "x"', True), + ("maximum_cardinality: 1", 'ex:tag "x"', False), + ("maximum_cardinality: 1", 'ex:tag "x", "y"', True), + ], +) +def test_class_expression_none_of_takes_cardinality_literally(condition, properties, expected): + """Inside none_of only a condition silent on presence and cardinality is made to require the slot.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + f""" +slots: + tag: + multivalued: true +classes: + Thing: + class_uri: ex:Thing + slots: [tag] + none_of: + - slot_conditions: + tag: + {condition} +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:t a ex:Thing {";" if properties else ""} {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +def test_class_expression_equals_string_on_enum_uses_the_meaning(): + """equals_string on an enum slot compares against the permissible value as _add_enum renders it.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +enums: + FormatEnum: + permissible_values: + OSI: + meaning: ex:OSI + MCAP: {} +slots: + fileFormat: + range: FormatEnum + hasChannel: {} +classes: + Format: + class_uri: ex:Format + slots: [fileFormat, hasChannel] + any_of: + - slot_conditions: + fileFormat: + equals_string: OSI + - slot_conditions: + fileFormat: + equals_string: MCAP + hasChannel: + required: true +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False).serialize() + g = rdflib.Graph().parse(data=shacl_ttl) + first, second = _list_members(g, EX_CE.Format, SH["or"]) + (osi_in,) = g.objects(_condition(g, first, EX_CE.fileFormat), SH["in"]) + assert list(Collection(g, osi_in)) == [EX_CE.OSI] + (mcap_in,) = g.objects(_condition(g, second, EX_CE.fileFormat), SH["in"]) + assert list(Collection(g, mcap_in)) == [Literal("MCAP")] + + prefix = "@prefix ex: ." + assert _conforms(shacl_ttl, f"{prefix} ex:f a ex:Format ; ex:fileFormat ex:OSI .") + assert not _conforms(shacl_ttl, f'{prefix} ex:f a ex:Format ; ex:fileFormat "MCAP" .') + + +@pytest.mark.parametrize("value,expected", [("5.0", True), ("5", True), ("6.0", False)]) +def test_class_expression_equals_number_compares_values(value, expected): + """equals_number matches the value, whatever numeric datatype carries it.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + size: + range: float + label: {} +classes: + Thing: + class_uri: ex:Thing + slots: [size, label] + any_of: + - slot_conditions: + size: + equals_number: 5 + - slot_conditions: + label: + required: true +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False, closed=False).serialize() + # sh:datatype xsd:float on the class's own property shape would reject an xsd:decimal, + # so every value is given as xsd:float + data = f""" + @prefix ex: . + @prefix xsd: . + ex:t a ex:Thing ; ex:size "{value}"^^xsd:float . + """ + assert _conforms(shacl_ttl, data) is expected + + +_UNTRANSLATABLE_CONDITIONS = { + # an identifier is the node's IRI; here it becomes one only through slot_usage + "identifier": """ +slots: + id: {} + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [id, a] + slot_usage: + id: + identifier: true + any_of: + - slot_conditions: + id: + required: true + - slot_conditions: + a: + required: true +""", + "'undefined', which is not a slot": """ +slots: + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [a] + any_of: + - slot_conditions: + undefined: + required: true + - slot_conditions: + a: + required: true +""", + "does not hold strings": """ +slots: + count: + range: integer + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [count, a] + any_of: + - slot_conditions: + count: + equals_string: "5" + - slot_conditions: + a: + required: true +""", +} + + +@pytest.mark.parametrize("reason", list(_UNTRANSLATABLE_CONDITIONS)) +def test_class_expression_untranslatable_condition_skipped_with_warning(caplog, reason): + """A condition on an identifier, on no slot, or equals_string on a non-string range is not translated.""" + import logging + + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = _parse_shacl(_CLASS_EXPRESSION_HEADER + _UNTRANSLATABLE_CONDITIONS[reason]) + + assert (EX_CE.Thing, SH["or"], None) not in g + assert any("any_of" in rec.message and reason in rec.message for rec in caplog.records) + + +def test_class_expression_is_a_with_native_names_uses_sh_node(): + """With native names, is_a references the member class's shape, suffix included, as a range does.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + a: {} +classes: + Marker: + class_uri: ex:Marker + Thing: + class_uri: ex:Thing + slots: [a] + any_of: + - is_a: Marker + - slot_conditions: + a: + required: true +""" + ) + g = _parse_shacl(schema, use_class_uri_names=False, suffix="Shape") + + (first, _) = _list_members(g, EX_CE.ThingShape, SH["or"]) + assert set(g.objects(first, SH.node)) == {EX_CE.MarkerShape} + assert (first, SH["class"], None) not in g + assert (EX_CE.MarkerShape, RDF.type, SH.NodeShape) in g + + +def _condition_property_shapes(g: rdflib.Graph, shape: URIRef, path: URIRef) -> list: + """The property shapes on *path* that the logical constraints of *shape* reach.""" + own = set(g.objects(shape, SH.property)) + return [node for node in g.transitive_objects(shape, None) if (node, SH.path, path) in g and node not in own] + + +def _class_expression_shacl(classes: str, **kwargs) -> rdflib.Graph: + """The SHACL shapes graph of a class-expression schema with the given ``slots:`` / ``classes:`` YAML.""" + return _parse_shacl(_CLASS_EXPRESSION_HEADER + classes, **kwargs) + + +def test_class_expression_condition_title_names_the_property_shape(): + """A condition's title becomes the sh:name of its property shape.""" + g = _class_expression_shacl( + """ +slots: + a: {} + b: {} +classes: + Thing: + class_uri: ex:Thing + slots: [a, b] + any_of: + - slot_conditions: + a: + title: the a condition + required: true + - slot_conditions: + b: + required: true +""" + ) + (condition,) = _condition_property_shapes(g, EX_CE.Thing, EX_CE.a) + assert set(g.objects(condition, SH.name)) == {Literal("the a condition")} + + +@pytest.mark.parametrize("values,conforms", [('"a", "b"', True), ('"a", "b", "c"', False)]) +def test_class_expression_strictest_maximum_applies(values, conforms): + """With an exact and a maximum cardinality, the stricter bound applies.""" + shacl_ttl = _class_expression_shacl( + """ +slots: + tag: + multivalued: true +classes: + Thing: + class_uri: ex:Thing + slots: [tag] + all_of: + - slot_conditions: + tag: + exact_cardinality: 2 + maximum_cardinality: 3 +""" + ).serialize(format="turtle") + assert _conforms(shacl_ttl, f"{_CE_PREFIXES}ex:t a ex:Thing ; ex:tag {values} .") == conforms + + +@pytest.mark.parametrize("ref,conforms", [(None, True), ("ex:s", False), ("ex:o", True)]) +def test_class_expression_none_of_range(ref, conforms): + """A range in a condition is a value constraint: within none_of it requires + the slot in its "definitely true" form, so only a Special value violates.""" + shacl_ttl = _class_expression_shacl( + """ +slots: + ref: + range: Target +classes: + Target: + class_uri: ex:Target + Special: + class_uri: ex:Special + Thing: + class_uri: ex:Thing + slots: [ref] + none_of: + - slot_conditions: + ref: + range: Special +""", + closed=False, + ).serialize(format="turtle") + value = f" ; ex:ref {ref}" if ref else "" + data = f"{_CE_PREFIXES}ex:s a ex:Special, ex:Target . ex:o a ex:Target . ex:t a ex:Thing{value} ." + assert _conforms(shacl_ttl, data) == conforms + + +def test_class_expression_equals_string_uses_the_condition_range(): + """equals_string is rendered for the condition's own range: an enum value + with a meaning becomes that IRI.""" + g = _class_expression_shacl( + """ +enums: + FormatEnum: + permissible_values: + OSI: + meaning: ex:OSI +slots: + fileFormat: {} + other: {} +classes: + Format: + class_uri: ex:Format + slots: [fileFormat, other] + any_of: + - slot_conditions: + fileFormat: + range: FormatEnum + equals_string: OSI + - slot_conditions: + other: + required: true +""" + ) + (condition,) = _condition_property_shapes(g, EX_CE.Format, EX_CE.fileFormat) + in_lists = [list(Collection(g, node)) for node in g.objects(condition, SH["in"])] + in_lists += [ + list(Collection(g, node)) + for and_list in g.objects(condition, SH["and"]) + for member in Collection(g, and_list) + for node in g.objects(member, SH["in"]) + ] + assert in_lists and all(values == [EX_CE.OSI] for values in in_lists) + + +def test_class_expression_equals_string_on_a_custom_string_type(): + """A type derived from string holds strings, so equals_string on it is translated.""" + g = _class_expression_shacl( + """ +types: + Code: + typeof: string +slots: + code: + range: Code + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [code, a] + any_of: + - slot_conditions: + code: + equals_string: AB + - slot_conditions: + a: + required: true +""" + ) + assert (EX_CE.Thing, SH["or"], None) in g + + +def test_class_expression_builtin_range_without_types_import(): + """A built-in range name in a schema that doesn't import linkml:types gives its datatype.""" + schema = """ +id: https://example.org/class-expressions +name: class_expressions +prefixes: + ex: https://example.org/class-expressions/ +default_prefix: ex +slots: + size: {} + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [size, a] + any_of: + - slot_conditions: + size: + range: integer + - slot_conditions: + a: + required: true +""" + g = _parse_shacl(schema) + (condition,) = _condition_property_shapes(g, EX_CE.Thing, EX_CE.size) + assert (condition, SH.datatype, XSD.integer) in g + + +def test_class_expression_range_with_identifier_is_an_iri(): + """A condition ranged on a class with an identifier expects IRIs, as a slot's range does.""" + g = _class_expression_shacl( + """ +slots: + id: + identifier: true + ref: {} + a: {} +classes: + Target: + class_uri: ex:Target + slots: [id] + Thing: + class_uri: ex:Thing + slots: [ref, a] + any_of: + - slot_conditions: + ref: + range: Target + - slot_conditions: + a: + required: true +""" + ) + (condition,) = _condition_property_shapes(g, EX_CE.Thing, EX_CE.ref) + assert set(g.objects(condition, SH.nodeKind)) == {SH.IRI} + + +_UNTRANSLATABLE_MEMBERS = { + "untranslatable-second-member": ( + "all_members", + """ + - slot_conditions: + a: + required: true + - slot_conditions: + tags: + all_members: + equals_string: x""", + ), + "unknown-condition-range": ( + "unknown range", + """ + - slot_conditions: + a: + range: Undefined + - slot_conditions: + b: + required: true""", + ), + "untranslatable-nested-member": ( + "all_members", + """ + - all_of: + - slot_conditions: + tags: + all_members: + equals_string: x + - slot_conditions: + a: + required: true""", + ), + "is_a-not-a-class": ( + "not a known class, type or enum", + """ + - is_a: Undefined + - slot_conditions: + a: + required: true""", + ), + "equals_string-on-class-range": ( + "does not hold strings", + """ + - slot_conditions: + ref: + equals_string: x + - slot_conditions: + a: + required: true""", + ), + "equals_string_in-on-integer": ( + "does not hold strings", + """ + - slot_conditions: + count: + equals_string_in: ["5"] + - slot_conditions: + a: + required: true""", + ), +} + + +@pytest.mark.parametrize("case", list(_UNTRANSLATABLE_MEMBERS)) +def test_class_expression_untranslatable_member_skips_the_operator(caplog, case): + """An operator with an untranslatable member anywhere, at any depth or + position, is skipped as a whole, with a warning naming the reason.""" + reason, members = _UNTRANSLATABLE_MEMBERS[case] + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + tags: + multivalued: true + ref: + range: Target + count: + range: integer + a: {} + b: {} +classes: + Target: + class_uri: ex:Target + Thing: + class_uri: ex:Thing + slots: [tags, ref, count, a, b] + any_of:""" + + members + + "\n" + ) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = _parse_shacl(schema) + assert (EX_CE.Thing, SH["or"], None) not in g + assert any("any_of" in rec.message and reason in rec.message for rec in caplog.records), caplog.text + + +_REPEATED_PARAMETERS_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +types: + Code: + typeof: string + pattern: "^[A-Z]+$" +enums: + LetterEnum: + permissible_values: + A: {} + B: {} + C: {} +slots: + size: + range: integer + label: {} + letter: {} + code: {} + other: {} +classes: + Thing: + class_uri: ex:Thing + slots: [size, label, letter, code, other] + any_of: + - slot_conditions: + size: + minimum_value: 3 + equals_number: 5 + label: + equals_string: A + equals_string_in: [A, B] + letter: + range: LetterEnum + equals_string: B + code: + range: Code + pattern: "^AB" + - slot_conditions: + other: + required: true +""" +) + + +@pytest.mark.parametrize( + "properties,expected", + [ + ('ex:size 5 ; ex:label "A" ; ex:letter "B" ; ex:code "ABC"', True), + ("ex:size 4", False), + ('ex:label "B"', False), + ('ex:letter "A"', False), + ('ex:code "ABc"', False), + ('ex:code "XY"', False), + ('ex:size 4 ; ex:other "x"', True), + ], +) +def test_class_expression_repeated_parameters_stay_well_formed(properties, expected): + """A parameter SHACL allows once per shape, needed twice by one condition, goes into sh:and. + + _conforms runs pyshacl with meta_shacl, which fails on an ill-formed shapes graph. + """ + shacl_ttl = ShaclGenerator(_REPEATED_PARAMETERS_SCHEMA, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:t a ex:Thing ; {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +@pytest.mark.parametrize( + "extra,properties,expected", + [ + ("", "", True), + ("maximum_cardinality: 5", "", True), + ("minimum_cardinality: 0", "", True), + ("maximum_cardinality: 5", 'ex:tag "A"', False), + ("maximum_cardinality: 5", 'ex:tag "B"', True), + ], +) +def test_class_expression_none_of_presence_is_monotonic(extra, properties, expected): + """Inside none_of, a cardinality that an absent slot satisfies does not flip an absent slot to rejected.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + f""" +slots: + tag: + multivalued: true +classes: + Thing: + class_uri: ex:Thing + slots: [tag] + none_of: + - slot_conditions: + tag: + equals_string: A + {extra} +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:t a ex:Thing {";" if properties else ""} {properties} . + """ + assert _conforms(shacl_ttl, data) is expected diff --git a/tests/linkml/test_issues/__snapshots__/issue_163d.ttl b/tests/linkml/test_issues/__snapshots__/issue_163d.ttl index f1d4f8996d..c97ee5dfd9 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_163d.ttl +++ b/tests/linkml/test_issues/__snapshots__/issue_163d.ttl @@ -6,8 +6,9 @@ @prefix linkml: . @prefix ex: . ex:C1 a linkml:ClassDefinition ; - skos:exactMatch "ex:C1"^^xsd:anyURI , "ex:mapping1"^^xsd:anyURI ; + skos:exactMatch "ex:mapping1"^^xsd:anyURI ; skos:inScheme "http://example.org/sample/example163d"^^xsd:anyURI ; + linkml:class_uri "http://example.org/C1"^^xsd:anyURI ; linkml:definition_uri "http://example.org/C1"^^xsd:anyURI ; linkml:slot_usage _:c14n1 . ex:boolean a linkml:TypeDefinition ; diff --git a/tests/linkml/test_issues/__snapshots__/issue_167.yaml b/tests/linkml/test_issues/__snapshots__/issue_167.yaml index 613aa7ba5f..595baadb55 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_167.yaml +++ b/tests/linkml/test_issues/__snapshots__/issue_167.yaml @@ -79,8 +79,6 @@ classes: tag: EX:foo3 value: bar3 from_schema: http://example.org/tests/issue167 - exact_mappings: - - http://example.org/tests/issue167/C1 slots: - sa - sb @@ -90,8 +88,6 @@ classes: name: c2 definition_uri: http://example.org/tests/issue167/C2 from_schema: http://example.org/tests/issue167 - exact_mappings: - - http://example.org/tests/issue167/C2 slots: - sb class_uri: http://example.org/tests/issue167/C2 diff --git a/tests/linkml/test_issues/__snapshots__/issue_167b.yaml b/tests/linkml/test_issues/__snapshots__/issue_167b.yaml index 5cc5cee876..cfe6a774ba 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_167b.yaml +++ b/tests/linkml/test_issues/__snapshots__/issue_167b.yaml @@ -370,8 +370,6 @@ classes: description: Annotations as tag value pairs. Note that altLabel is defined in the default namespace, not in the SKOS namespace from_schema: http://example.org/tests/issue167b - exact_mappings: - - ex:MyClass class_uri: ex:MyClass my class 2: name: my class 2 @@ -388,8 +386,6 @@ classes: to be annotated. Note, however, that the annotation source is NOT a CURIE, rather just a string. from_schema: http://example.org/tests/issue167b - exact_mappings: - - ex:MyClass2 class_uri: ex:MyClass2 annotatable: name: annotatable @@ -397,8 +393,6 @@ classes: description: mixin for classes that support annotations from_schema: https://w3id.org/linkml/annotations imported_from: linkml:annotations - exact_mappings: - - linkml:Annotatable mixin: true slots: - annotations @@ -409,8 +403,6 @@ classes: description: a tag/value pair with the semantics of OWL Annotation from_schema: https://w3id.org/linkml/annotations imported_from: linkml:annotations - exact_mappings: - - linkml:Annotation is_a: extension mixins: - annotatable @@ -434,8 +426,6 @@ classes: description: a tag/value pair used to add non-model information to an entry from_schema: https://w3id.org/linkml/extensions imported_from: linkml:extensions - exact_mappings: - - linkml:Extension slots: - extension_tag - extension_value @@ -447,8 +437,6 @@ classes: description: mixin for classes that support extension from_schema: https://w3id.org/linkml/extensions imported_from: linkml:extensions - exact_mappings: - - linkml:Extensible mixin: true slots: - extensions diff --git a/tests/linkml/test_issues/__snapshots__/issue_177_error.yaml b/tests/linkml/test_issues/__snapshots__/issue_177_error.yaml index f691dad688..ee96698d2b 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_177_error.yaml +++ b/tests/linkml/test_issues/__snapshots__/issue_177_error.yaml @@ -14,24 +14,18 @@ classes: name: c1 definition_uri: http://example.org/tests/issue177/C1 from_schema: http://example.org/tests/issue177 - exact_mappings: - - http://example.org/tests/issue177/C1 class_uri: http://example.org/tests/issue177/C1 tree_root: true c2: name: c2 definition_uri: http://example.org/tests/issue177/C2 from_schema: http://example.org/tests/issue177 - exact_mappings: - - http://example.org/tests/issue177/C2 class_uri: http://example.org/tests/issue177/C2 tree_root: true c3: name: c3 definition_uri: http://example.org/tests/issue177/C3 from_schema: http://example.org/tests/issue177 - exact_mappings: - - http://example.org/tests/issue177/C3 class_uri: http://example.org/tests/issue177/C3 tree_root: true metamodel_version: 1.12.0 diff --git a/tests/linkml/test_issues/__snapshots__/issue_18.yaml b/tests/linkml/test_issues/__snapshots__/issue_18.yaml index de9ac80904..7eacf7a19b 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_18.yaml +++ b/tests/linkml/test_issues/__snapshots__/issue_18.yaml @@ -82,8 +82,6 @@ classes: name: c1 definition_uri: http://example.org/tests/issue18/C1 from_schema: http://example.org/tests/issue18 - exact_mappings: - - http://example.org/tests/issue18/C1 slots: - c1s1 - c1s2 @@ -92,8 +90,6 @@ classes: name: c2 definition_uri: http://example.org/tests/issue18/C2 from_schema: http://example.org/tests/issue18 - exact_mappings: - - http://example.org/tests/issue18/C2 slots: - c2s1 - c2s1 @@ -102,8 +98,6 @@ classes: name: c3 definition_uri: http://example.org/tests/issue18/C3 from_schema: http://example.org/tests/issue18 - exact_mappings: - - http://example.org/tests/issue18/C3 slots: - c3s1 class_uri: http://example.org/tests/issue18/C3 @@ -111,8 +105,6 @@ classes: name: c4 definition_uri: http://example.org/tests/issue18/C4 from_schema: http://example.org/tests/issue18 - exact_mappings: - - http://example.org/tests/issue18/C4 class_uri: http://example.org/tests/issue18/C4 metamodel_version: 1.12.0 source_file: issue_18.yaml diff --git a/tests/linkml/test_issues/__snapshots__/linkml_issue_384.other.txt b/tests/linkml/test_issues/__snapshots__/linkml_issue_384.other.txt index 86a728f38c..1755c9215a 100644 --- a/tests/linkml/test_issues/__snapshots__/linkml_issue_384.other.txt +++ b/tests/linkml/test_issues/__snapshots__/linkml_issue_384.other.txt @@ -443,8 +443,6 @@ classes: name: Thing definition_uri: https://w3id.org/linkml/examples/personinfo/Thing from_schema: https://w3id.org/linkml/examples/personinfo - exact_mappings: - - ex:Thing slots: - id - full_name @@ -482,8 +480,6 @@ classes: name: Organization definition_uri: https://w3id.org/linkml/examples/personinfo/Organization from_schema: https://w3id.org/linkml/examples/personinfo - exact_mappings: - - ex:Organization is_a: Thing slots: - id @@ -501,8 +497,6 @@ classes: name: GeoObject definition_uri: https://w3id.org/linkml/examples/personinfo/GeoObject from_schema: https://w3id.org/linkml/examples/personinfo - exact_mappings: - - ex:GeoObject is_a: Thing slots: - id @@ -527,8 +521,6 @@ classes: name: GeoAge definition_uri: https://w3id.org/linkml/examples/personinfo/GeoAge from_schema: https://w3id.org/linkml/examples/personinfo - exact_mappings: - - ex:GeoAge slots: - geoAge__unit - geoAge__value @@ -600,19 +592,19 @@ generation_date: '2000-01-01T00:00:00' # --- linkml_issue_384.ttl --- . - "ex:GeoAge"^^ . "https://w3id.org/linkml/examples/personinfo"^^ . . . + "https://w3id.org/linkml/examples/personinfo/GeoAge"^^ . "https://w3id.org/linkml/examples/personinfo/GeoAge"^^ . _:c14n2 . . . . - "ex:GeoObject"^^ . "https://w3id.org/linkml/examples/personinfo"^^ . . . + "https://w3id.org/linkml/examples/personinfo/GeoObject"^^ . "https://w3id.org/linkml/examples/personinfo/GeoObject"^^ . . _:c14n0 . @@ -621,8 +613,8 @@ generation_date: '2000-01-01T00:00:00' . . . - "ex:Organization"^^ . "https://w3id.org/linkml/examples/personinfo"^^ . + "https://w3id.org/linkml/examples/personinfo/Organization"^^ . "https://w3id.org/linkml/examples/personinfo/Organization"^^ . . _:c14n6 . @@ -664,6 +656,7 @@ generation_date: '2000-01-01T00:00:00' . . . + "http://schema.org/Person"^^ . "https://w3id.org/linkml/examples/personinfo/Person"^^ . . _:c14n1 . @@ -689,8 +682,8 @@ generation_date: '2000-01-01T00:00:00' "https://w3id.org/linkml/examples/personinfo/parent"^^ . "parent" . . - "ex:Thing"^^ . "https://w3id.org/linkml/examples/personinfo"^^ . + "https://w3id.org/linkml/examples/personinfo/Thing"^^ . "https://w3id.org/linkml/examples/personinfo/Thing"^^ . _:c14n4 . . diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.json b/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.json index f081ec8dfb..2db77bbf7a 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.json +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.json @@ -1532,9 +1532,6 @@ "name": "AnyOfSimpleType", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfSimpleType" - ], "slots": [ "anyOfSimpleType__attribute1" ], @@ -1551,15 +1548,13 @@ } ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType" }, { "name": "AnyOfClasses", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfClasses" - ], "slots": [ "anyOfClasses__attribute2" ], @@ -1576,15 +1571,13 @@ } ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses" }, { "name": "AnyOfEnums", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfEnums" - ], "slots": [ "anyOfEnums__attribute3" ], @@ -1601,15 +1594,13 @@ } ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums" }, { "name": "AnyOfMix", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfMix" - ], "slots": [ "anyOfMix__attribute4" ], @@ -1629,15 +1620,13 @@ } ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix" }, { "name": "EqualsString", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsString", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EqualsString" - ], "slots": [ "equalsString__attribute5" ], @@ -1647,15 +1636,13 @@ "name": "attribute5", "equals_string": "foo" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsString" }, { "name": "EqualsStringIn", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EqualsStringIn" - ], "slots": [ "equalsStringIn__attribute6" ], @@ -1668,15 +1655,13 @@ "bar" ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn" }, { "name": "HasAliases", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/HasAliases", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:HasAliases" - ], "mixin": true, "slots": [ "hasAliases__aliases" @@ -1688,20 +1673,19 @@ "slot_uri": "skos:altLabel", "multivalued": true } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/HasAliases" }, { "name": "Friend", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Friend", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Friend" - ], "abstract": true, "slots": [ "name" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Friend" }, { "name": "Person", @@ -1737,7 +1721,6 @@ "schema:Person" ], "exact_mappings": [ - "ks:Person", "schema:Person" ], "rank": 2, @@ -1764,7 +1747,8 @@ "name": "is_living", "range": "LifeStatusEnum" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Person" }, { "name": "Organization", @@ -1774,9 +1758,6 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Organization", "description": "An organization.\n\nThis description\nincludes newlines\n\n## Markdown headers\n\n * and\n * a\n * list", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Organization" - ], "rank": 3, "mixins": [ "HasAliases" @@ -1786,15 +1767,13 @@ "name", "hasAliases__aliases" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Organization" }, { "name": "Place", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Place", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Place" - ], "mixins": [ "HasAliases" ], @@ -1803,21 +1782,20 @@ "name", "hasAliases__aliases" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Place" }, { "name": "Address", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Address", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Address" - ], "slots": [ "street", "city", "altitude" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Address" }, { "name": "Concept", @@ -1826,23 +1804,18 @@ ], "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Concept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Concept" - ], "slots": [ "id", "name", "in_code_system" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Concept" }, { "name": "DiagnosisConcept", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:DiagnosisConcept" - ], "close_mappings": [ "biolink:Disease" ], @@ -1852,45 +1825,39 @@ "name", "in_code_system" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept" }, { "name": "ProcedureConcept", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:ProcedureConcept" - ], "is_a": "Concept", "slots": [ "id", "name", "in_code_system" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept" }, { "name": "Event", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Event", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Event" - ], "slots": [ "started_at_time", "ended_at_time", "is_current", "metadata" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Event" }, { "name": "Relationship", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Relationship", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Relationship" - ], "slots": [ "started_at_time", "ended_at_time", @@ -1898,15 +1865,13 @@ "type", "Relationship_cordialness" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Relationship" }, { "name": "FamilialRelationship", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:FamilialRelationship" - ], "rank": 5, "is_a": "Relationship", "slots": [ @@ -1916,15 +1881,13 @@ "FamilialRelationship_type", "FamilialRelationship_related_to" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship" }, { "name": "BirthEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/BirthEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:BirthEvent" - ], "is_a": "Event", "slots": [ "started_at_time", @@ -1933,15 +1896,13 @@ "metadata", "in_location" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/BirthEvent" }, { "name": "EmploymentEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EmploymentEvent" - ], "rank": 6, "is_a": "Event", "slots": [ @@ -1952,15 +1913,13 @@ "employed_at", "EmploymentEvent_type" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent" }, { "name": "MedicalEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:MedicalEvent" - ], "is_a": "Event", "slots": [ "started_at_time", @@ -1971,28 +1930,24 @@ "diagnosis", "procedure" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent" }, { "name": "WithLocation", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/WithLocation", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:WithLocation" - ], "mixin": true, "slots": [ "in_location" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/WithLocation" }, { "name": "MarriageEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:MarriageEvent" - ], "is_a": "Event", "mixins": [ "WithLocation" @@ -2005,15 +1960,13 @@ "married_to", "in_location" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent" }, { "name": "Company", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Company", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Company" - ], "is_a": "Organization", "slots": [ "id", @@ -2028,29 +1981,25 @@ "slot_uri": "schema:ceo", "range": "Person" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Company" }, { "name": "CodeSystem", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/CodeSystem", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:CodeSystem" - ], "slots": [ "id", "name", "long_id" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/CodeSystem" }, { "name": "Dataset", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Dataset", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Dataset" - ], "rank": 1, "slots": [ "metadata", @@ -2089,6 +2038,7 @@ "inlined_as_list": true } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Dataset", "tree_root": true }, { @@ -2096,9 +2046,6 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/FakeClass", "deprecated": "this is not a real class, we are using it to test deprecation", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:FakeClass" - ], "slots": [ "fakeClass__test_attribute" ], @@ -2107,15 +2054,13 @@ { "name": "test_attribute" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/FakeClass" }, { "name": "ClassWithSpaces", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:ClassWithSpaces" - ], "slots": [ "classWithSpaces__slot_with_space_1" ], @@ -2124,15 +2069,13 @@ { "name": "slot_with_space_1" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces" }, { "name": "SubclassTest", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubclassTest", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:SubclassTest" - ], "is_a": "ClassWithSpaces", "slots": [ "classWithSpaces__slot_with_space_1", @@ -2144,36 +2087,33 @@ "name": "slot_with_space_2", "range": "ClassWithSpaces" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubclassTest" }, { "name": "SubSubClass2", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:SubSubClass2" - ], "is_a": "SubclassTest", "slots": [ "classWithSpaces__slot_with_space_1", "subclassTest__slot_with_space_2" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2" }, { "name": "TubSubClass1", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1", "description": "Same depth as Sub sub class 1", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:TubSubClass1" - ], "is_a": "SubclassTest", "slots": [ "classWithSpaces__slot_with_space_1", "subclassTest__slot_with_space_2" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1" }, { "name": "AnyObject", @@ -2183,7 +2123,8 @@ "exact_mappings": [ "linkml:Any" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any" }, { "name": "Activity", @@ -2193,9 +2134,6 @@ "mappings": [ "prov:Activity" ], - "exact_mappings": [ - "core:Activity" - ], "slots": [ "id", "started_at_time", @@ -2205,7 +2143,8 @@ "used", "description" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/core/Activity" }, { "name": "Agent", @@ -2220,7 +2159,8 @@ "acted_on_behalf_of", "was_informed_by" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "http://www.w3.org/ns/prov#Agent" } ], "metamodel_version": "1.12.0", diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.jsonld index dd2c8b077f..f4ea08ea9b 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.jsonld @@ -1635,9 +1635,6 @@ "name": "AnyOfSimpleType", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfSimpleType" - ], "slots": [ "anyOfSimpleType__attribute1" ], @@ -1658,15 +1655,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType", "@type": "ClassDefinition" }, { "name": "AnyOfClasses", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfClasses" - ], "slots": [ "anyOfClasses__attribute2" ], @@ -1687,15 +1682,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses", "@type": "ClassDefinition" }, { "name": "AnyOfEnums", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfEnums" - ], "slots": [ "anyOfEnums__attribute3" ], @@ -1716,15 +1709,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums", "@type": "ClassDefinition" }, { "name": "AnyOfMix", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfMix" - ], "slots": [ "anyOfMix__attribute4" ], @@ -1749,15 +1740,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix", "@type": "ClassDefinition" }, { "name": "EqualsString", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsString", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EqualsString" - ], "slots": [ "equalsString__attribute5" ], @@ -1769,15 +1758,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsString", "@type": "ClassDefinition" }, { "name": "EqualsStringIn", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EqualsStringIn" - ], "slots": [ "equalsStringIn__attribute6" ], @@ -1792,15 +1779,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn", "@type": "ClassDefinition" }, { "name": "HasAliases", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/HasAliases", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:HasAliases" - ], "mixin": true, "slots": [ "hasAliases__aliases" @@ -1814,20 +1799,19 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/HasAliases", "@type": "ClassDefinition" }, { "name": "Friend", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Friend", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Friend" - ], "abstract": true, "slots": [ "name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Friend", "@type": "ClassDefinition" }, { @@ -1868,7 +1852,6 @@ "schema:Person" ], "exact_mappings": [ - "ks:Person", "schema:Person" ], "rank": 2, @@ -1897,6 +1880,7 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Person", "@type": "ClassDefinition" }, { @@ -1907,9 +1891,6 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Organization", "description": "An organization.\n\nThis description\nincludes newlines\n\n## Markdown headers\n\n * and\n * a\n * list", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Organization" - ], "rank": 3, "mixins": [ "HasAliases" @@ -1920,15 +1901,13 @@ "hasAliases__aliases" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Organization", "@type": "ClassDefinition" }, { "name": "Place", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Place", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Place" - ], "mixins": [ "HasAliases" ], @@ -1938,21 +1917,20 @@ "hasAliases__aliases" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Place", "@type": "ClassDefinition" }, { "name": "Address", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Address", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Address" - ], "slots": [ "street", "city", "altitude" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Address", "@type": "ClassDefinition" }, { @@ -1962,24 +1940,19 @@ ], "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Concept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Concept" - ], "slots": [ "id", "name", "in_code_system" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Concept", "@type": "ClassDefinition" }, { "name": "DiagnosisConcept", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:DiagnosisConcept" - ], "close_mappings": [ "biolink:Disease" ], @@ -1990,15 +1963,13 @@ "in_code_system" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept", "@type": "ClassDefinition" }, { "name": "ProcedureConcept", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:ProcedureConcept" - ], "is_a": "Concept", "slots": [ "id", @@ -2006,15 +1977,13 @@ "in_code_system" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept", "@type": "ClassDefinition" }, { "name": "Event", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Event", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Event" - ], "slots": [ "started_at_time", "ended_at_time", @@ -2022,15 +1991,13 @@ "metadata" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Event", "@type": "ClassDefinition" }, { "name": "Relationship", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Relationship", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Relationship" - ], "slots": [ "started_at_time", "ended_at_time", @@ -2039,15 +2006,13 @@ "Relationship_cordialness" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Relationship", "@type": "ClassDefinition" }, { "name": "FamilialRelationship", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:FamilialRelationship" - ], "rank": 5, "is_a": "Relationship", "slots": [ @@ -2058,15 +2023,13 @@ "FamilialRelationship_related_to" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship", "@type": "ClassDefinition" }, { "name": "BirthEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/BirthEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:BirthEvent" - ], "is_a": "Event", "slots": [ "started_at_time", @@ -2076,15 +2039,13 @@ "in_location" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/BirthEvent", "@type": "ClassDefinition" }, { "name": "EmploymentEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EmploymentEvent" - ], "rank": 6, "is_a": "Event", "slots": [ @@ -2096,15 +2057,13 @@ "EmploymentEvent_type" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent", "@type": "ClassDefinition" }, { "name": "MedicalEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:MedicalEvent" - ], "is_a": "Event", "slots": [ "started_at_time", @@ -2116,29 +2075,25 @@ "procedure" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent", "@type": "ClassDefinition" }, { "name": "WithLocation", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/WithLocation", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:WithLocation" - ], "mixin": true, "slots": [ "in_location" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/WithLocation", "@type": "ClassDefinition" }, { "name": "MarriageEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:MarriageEvent" - ], "is_a": "Event", "mixins": [ "WithLocation" @@ -2152,15 +2107,13 @@ "in_location" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent", "@type": "ClassDefinition" }, { "name": "Company", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Company", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Company" - ], "is_a": "Organization", "slots": [ "id", @@ -2177,30 +2130,26 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Company", "@type": "ClassDefinition" }, { "name": "CodeSystem", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/CodeSystem", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:CodeSystem" - ], "slots": [ "id", "name", "long_id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/CodeSystem", "@type": "ClassDefinition" }, { "name": "Dataset", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Dataset", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Dataset" - ], "rank": 1, "slots": [ "metadata", @@ -2243,6 +2192,7 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Dataset", "tree_root": true, "@type": "ClassDefinition" }, @@ -2251,9 +2201,6 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/FakeClass", "deprecated": "this is not a real class, we are using it to test deprecation", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:FakeClass" - ], "slots": [ "fakeClass__test_attribute" ], @@ -2264,15 +2211,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/FakeClass", "@type": "ClassDefinition" }, { "name": "ClassWithSpaces", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:ClassWithSpaces" - ], "slots": [ "classWithSpaces__slot_with_space_1" ], @@ -2283,15 +2228,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces", "@type": "ClassDefinition" }, { "name": "SubclassTest", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubclassTest", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:SubclassTest" - ], "is_a": "ClassWithSpaces", "slots": [ "classWithSpaces__slot_with_space_1", @@ -2305,21 +2248,20 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubclassTest", "@type": "ClassDefinition" }, { "name": "SubSubClass2", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:SubSubClass2" - ], "is_a": "SubclassTest", "slots": [ "classWithSpaces__slot_with_space_1", "subclassTest__slot_with_space_2" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2", "@type": "ClassDefinition" }, { @@ -2327,15 +2269,13 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1", "description": "Same depth as Sub sub class 1", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:TubSubClass1" - ], "is_a": "SubclassTest", "slots": [ "classWithSpaces__slot_with_space_1", "subclassTest__slot_with_space_2" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1", "@type": "ClassDefinition" }, { @@ -2347,6 +2287,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -2357,9 +2298,6 @@ "mappings": [ "prov:Activity" ], - "exact_mappings": [ - "core:Activity" - ], "slots": [ "id", "started_at_time", @@ -2370,6 +2308,7 @@ "description" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/core/Activity", "@type": "ClassDefinition" }, { @@ -2386,6 +2325,7 @@ "was_informed_by" ], "slot_usage": {}, + "class_uri": "http://www.w3.org/ns/prov#Agent", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld index 3f4b89cb3a..55b28aa396 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld @@ -438,6 +438,7 @@ "state" ], "slot_usage": {}, + "class_uri": "http://samples.r.us/foo#model", "@type": "ClassDefinition" }, { @@ -449,6 +450,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -457,15 +459,13 @@ "description": "a tag/value pair used to add non-model information to an entry", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extension" - ], "slots": [ "extension_tag", "extension_value", "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extension", "@type": "ClassDefinition" }, { @@ -474,14 +474,12 @@ "description": "mixin for classes that support extension", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extensible" - ], "mixin": true, "slots": [ "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extensible", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_scripts/__snapshots__/genyaml/clean.yaml b/tests/linkml/test_scripts/__snapshots__/genyaml/clean.yaml index f665ed9b28..91df023ec9 100644 --- a/tests/linkml/test_scripts/__snapshots__/genyaml/clean.yaml +++ b/tests/linkml/test_scripts/__snapshots__/genyaml/clean.yaml @@ -316,8 +316,6 @@ classes: name: geographic location definition_uri: http://example.org/tests/cleanyaml/GeographicLocation from_schema: http://example.org/tests/cleanyaml - exact_mappings: - - http://example.org/tests/cleanyaml/GeographicLocation slots: - k class_uri: http://example.org/tests/cleanyaml/GeographicLocation @@ -325,8 +323,6 @@ classes: name: geographic location at time definition_uri: http://example.org/tests/cleanyaml/GeographicLocationAtTime from_schema: http://example.org/tests/cleanyaml - exact_mappings: - - http://example.org/tests/cleanyaml/GeographicLocationAtTime is_a: geographic location slots: - k diff --git a/tests/linkml/test_scripts/__snapshots__/genyaml/meta.yaml b/tests/linkml/test_scripts/__snapshots__/genyaml/meta.yaml index 014151bd4d..2519607ad8 100644 --- a/tests/linkml/test_scripts/__snapshots__/genyaml/meta.yaml +++ b/tests/linkml/test_scripts/__snapshots__/genyaml/meta.yaml @@ -1265,8 +1265,6 @@ classes: name: AnyOfSimpleType definition_uri: https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:AnyOfSimpleType slots: - anyOfSimpleType__attribute1 attributes: @@ -1280,8 +1278,6 @@ classes: name: AnyOfClasses definition_uri: https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:AnyOfClasses slots: - anyOfClasses__attribute2 attributes: @@ -1295,8 +1291,6 @@ classes: name: AnyOfEnums definition_uri: https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:AnyOfEnums slots: - anyOfEnums__attribute3 attributes: @@ -1310,8 +1304,6 @@ classes: name: AnyOfMix definition_uri: https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:AnyOfMix slots: - anyOfMix__attribute4 attributes: @@ -1326,8 +1318,6 @@ classes: name: EqualsString definition_uri: https://w3id.org/linkml/tests/kitchen_sink/EqualsString from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:EqualsString slots: - equalsString__attribute5 attributes: @@ -1339,8 +1329,6 @@ classes: name: EqualsStringIn definition_uri: https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:EqualsStringIn slots: - equalsStringIn__attribute6 attributes: @@ -1354,8 +1342,6 @@ classes: name: HasAliases definition_uri: https://w3id.org/linkml/tests/kitchen_sink/HasAliases from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:HasAliases mixin: true slots: - hasAliases__aliases @@ -1369,8 +1355,6 @@ classes: name: Friend definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Friend from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Friend abstract: true slots: - name @@ -1401,7 +1385,6 @@ classes: - https://en.wikipedia.org/wiki/Person - schema:Person exact_mappings: - - ks:Person - schema:Person rank: 2 mixins: @@ -1442,8 +1425,6 @@ classes: description: "An organization.\n\nThis description\nincludes newlines\n\n## Markdown\ \ headers\n\n * and\n * a\n * list" from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Organization rank: 3 mixins: - HasAliases @@ -1456,8 +1437,6 @@ classes: name: Place definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Place from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Place mixins: - HasAliases slots: @@ -1469,8 +1448,6 @@ classes: name: Address definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Address from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Address slots: - street - city @@ -1482,8 +1459,6 @@ classes: - CODE definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Concept from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Concept slots: - id - name @@ -1493,8 +1468,6 @@ classes: name: DiagnosisConcept definition_uri: https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:DiagnosisConcept close_mappings: - biolink:Disease is_a: Concept @@ -1507,8 +1480,6 @@ classes: name: ProcedureConcept definition_uri: https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:ProcedureConcept is_a: Concept slots: - id @@ -1519,8 +1490,6 @@ classes: name: Event definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Event from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Event slots: - started at time - ended at time @@ -1531,8 +1500,6 @@ classes: name: Relationship definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Relationship from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Relationship slots: - started at time - ended at time @@ -1548,8 +1515,6 @@ classes: name: FamilialRelationship definition_uri: https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:FamilialRelationship rank: 5 is_a: Relationship slots: @@ -1574,8 +1539,6 @@ classes: name: BirthEvent definition_uri: https://w3id.org/linkml/tests/kitchen_sink/BirthEvent from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:BirthEvent is_a: Event slots: - started at time @@ -1588,8 +1551,6 @@ classes: name: EmploymentEvent definition_uri: https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:EmploymentEvent rank: 6 is_a: Event slots: @@ -1611,8 +1572,6 @@ classes: name: MedicalEvent definition_uri: https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:MedicalEvent is_a: Event slots: - started at time @@ -1627,8 +1586,6 @@ classes: name: WithLocation definition_uri: https://w3id.org/linkml/tests/kitchen_sink/WithLocation from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:WithLocation mixin: true slots: - in location @@ -1637,8 +1594,6 @@ classes: name: MarriageEvent definition_uri: https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:MarriageEvent is_a: Event mixins: - WithLocation @@ -1654,8 +1609,6 @@ classes: name: Company definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Company from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Company is_a: Organization slots: - id @@ -1672,8 +1625,6 @@ classes: name: CodeSystem definition_uri: https://w3id.org/linkml/tests/kitchen_sink/CodeSystem from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:CodeSystem slots: - id - name @@ -1683,8 +1634,6 @@ classes: name: Dataset definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Dataset from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Dataset rank: 1 slots: - metadata @@ -1723,8 +1672,6 @@ classes: definition_uri: https://w3id.org/linkml/tests/kitchen_sink/FakeClass deprecated: this is not a real class, we are using it to test deprecation from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:FakeClass slots: - fakeClass__test_attribute attributes: @@ -1735,8 +1682,6 @@ classes: name: class with spaces definition_uri: https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:ClassWithSpaces slots: - classWithSpaces__slot_with_space_1 attributes: @@ -1747,8 +1692,6 @@ classes: name: subclass test definition_uri: https://w3id.org/linkml/tests/kitchen_sink/SubclassTest from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:SubclassTest is_a: class with spaces slots: - classWithSpaces__slot_with_space_1 @@ -1762,8 +1705,6 @@ classes: name: Sub sub class 2 definition_uri: https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2 from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:SubSubClass2 is_a: subclass test slots: - classWithSpaces__slot_with_space_1 @@ -1774,8 +1715,6 @@ classes: definition_uri: https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1 description: Same depth as Sub sub class 1 from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:TubSubClass1 is_a: subclass test slots: - classWithSpaces__slot_with_space_1 @@ -1796,8 +1735,6 @@ classes: from_schema: https://w3id.org/linkml/tests/core mappings: - prov:Activity - exact_mappings: - - core:Activity slots: - id - started at time diff --git a/tests/linkml/test_scripts/test_diff_stable.py b/tests/linkml/test_scripts/test_diff_stable.py new file mode 100644 index 0000000000..d1c292276b --- /dev/null +++ b/tests/linkml/test_scripts/test_diff_stable.py @@ -0,0 +1,45 @@ +"""CLI checks for the optional diff-stable RDF labels.""" + +import re +from pathlib import Path + +import pyoxigraph as ox +import pytest +from click.testing import CliRunner + +from linkml.generators.shaclgen import cli as shacl_cli + +_SCHEMA = """id: https://example.org/cli +name: cli +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/cli/ +default_prefix: ex +imports: [linkml:types] +classes: + Thing: + attributes: + label: + range: string +""" + + +@pytest.mark.parametrize( + ("flag", "label_pattern"), + [ + pytest.param("--no-diff-stable", r"c14n[0-9]+", id="disabled"), + pytest.param("--diff-stable", r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", id="enabled", marks=pytest.mark.diffable_rdf), + ], +) +def test_the_flag_reaches_the_serializer(tmp_path: Path, flag: str, label_pattern: str) -> None: + """The CLI flag selects the blank-node labelling it names.""" + schema = tmp_path / "schema.yaml" + schema.write_text(_SCHEMA, encoding="utf-8") + + result = CliRunner().invoke(shacl_cli, [str(schema), flag]) + + assert result.exit_code == 0, result.output + quads = list(ox.parse(result.stdout, format=ox.RdfFormat.TURTLE)) + blank_nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} + assert blank_nodes + assert all(re.fullmatch(label_pattern, node.value) for node in blank_nodes) diff --git a/tests/linkml/test_utils/__snapshots__/import_test_1.json b/tests/linkml/test_utils/__snapshots__/import_test_1.json index 9f7b1cb988..0ac73bb3bc 100644 --- a/tests/linkml/test_utils/__snapshots__/import_test_1.json +++ b/tests/linkml/test_utils/__snapshots__/import_test_1.json @@ -401,9 +401,6 @@ "name": "class_1", "definition_uri": "http://example.org/import_test_1#Class1", "from_schema": "http://example.org/import_test_1", - "exact_mappings": [ - "test:Class1" - ], "slots": [ "slot_1" ], @@ -413,9 +410,6 @@ "name": "class_2", "definition_uri": "http://example.org/import_test_2#Class2", "from_schema": "http://example.org/import_test_2", - "exact_mappings": [ - "test2:Class2" - ], "slots": [ "slot_2" ], @@ -425,9 +419,6 @@ "name": "class_3", "definition_uri": "http://example.org/import_test_3#Class3", "from_schema": "http://example.org/import_test_3", - "exact_mappings": [ - "test3:Class3" - ], "slots": [ "slot_3" ], @@ -437,9 +428,6 @@ "name": "class_4", "definition_uri": "http://example.org/import_test_4#Class4", "from_schema": "http://example.org/import_test_4", - "exact_mappings": [ - "test4:Class4" - ], "slots": [ "slot_4" ], diff --git a/tests/linkml/test_utils/__snapshots__/multi_usages.yaml b/tests/linkml/test_utils/__snapshots__/multi_usages.yaml index 3b1000a818..90ca30c93f 100644 --- a/tests/linkml/test_utils/__snapshots__/multi_usages.yaml +++ b/tests/linkml/test_utils/__snapshots__/multi_usages.yaml @@ -347,8 +347,6 @@ classes: name: root_class definition_uri: http://example.org/multi_usages/RootClass from_schema: http://example.org/multi_usages - exact_mappings: - - http://example.org/multi_usages/RootClass slots: - s1 class_uri: http://example.org/multi_usages/RootClass @@ -356,8 +354,6 @@ classes: name: child_class1 definition_uri: http://example.org/multi_usages/ChildClass1 from_schema: http://example.org/multi_usages - exact_mappings: - - http://example.org/multi_usages/ChildClass1 is_a: root_class slots: - child_class1_s1 @@ -372,8 +368,6 @@ classes: name: child_class2 definition_uri: http://example.org/multi_usages/ChildClass2 from_schema: http://example.org/multi_usages - exact_mappings: - - http://example.org/multi_usages/ChildClass2 is_a: child_class1 slots: - child_class2_s1 @@ -388,8 +382,6 @@ classes: name: child_class3 definition_uri: http://example.org/multi_usages/ChildClass3 from_schema: http://example.org/multi_usages - exact_mappings: - - http://example.org/multi_usages/ChildClass3 is_a: child_class2 slots: - child_class3_s1 diff --git a/tests/linkml/test_utils/__snapshots__/multi_usages_2.yaml b/tests/linkml/test_utils/__snapshots__/multi_usages_2.yaml index 00077ce6a3..7d94e068ef 100644 --- a/tests/linkml/test_utils/__snapshots__/multi_usages_2.yaml +++ b/tests/linkml/test_utils/__snapshots__/multi_usages_2.yaml @@ -350,8 +350,6 @@ classes: name: root_class definition_uri: http://example.org/multi_usages_2/RootClass from_schema: http://example.org/multi_usages_2 - exact_mappings: - - http://example.org/multi_usages_2/RootClass slots: - s1 class_uri: http://example.org/multi_usages_2/RootClass @@ -359,8 +357,6 @@ classes: name: child_class1 definition_uri: http://example.org/multi_usages_2/ChildClass1 from_schema: http://example.org/multi_usages_2 - exact_mappings: - - http://example.org/multi_usages_2/ChildClass1 is_a: root_class slots: - child_class1_s1 @@ -375,8 +371,6 @@ classes: name: child_class2 definition_uri: http://example.org/multi_usages_2/ChildClass2 from_schema: http://example.org/multi_usages_2 - exact_mappings: - - http://example.org/multi_usages_2/ChildClass2 is_a: child_class1 slots: - child_class2_s1 @@ -391,8 +385,6 @@ classes: name: child_class3 definition_uri: http://example.org/multi_usages_2/ChildClass3 from_schema: http://example.org/multi_usages_2 - exact_mappings: - - http://example.org/multi_usages_2/ChildClass3 is_a: child_class2 slots: - child_class3_s1 diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.json b/tests/linkml/test_utils/__snapshots__/uriandcurie.json index 944ae90e21..2c39326ff5 100644 --- a/tests/linkml/test_utils/__snapshots__/uriandcurie.json +++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.json @@ -160,9 +160,6 @@ "name": "C1", "definition_uri": "http://example.org/test/uriandcurieC1", "from_schema": "http://example.org/test/uriandcurie", - "exact_mappings": [ - "m:C1" - ], "slots": [ "id", "hasCurie", @@ -171,6 +168,7 @@ "id2" ], "slot_usage": {}, + "class_uri": "http://example.org/test/uriandcurieC1", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_utils/test_generator.py b/tests/linkml/test_utils/test_generator.py index 94d96a1545..ed3b17e663 100644 --- a/tests/linkml/test_utils/test_generator.py +++ b/tests/linkml/test_utils/test_generator.py @@ -7,6 +7,7 @@ import re from dataclasses import dataclass, field from io import BytesIO, StringIO +from pathlib import Path from typing import cast import click @@ -14,7 +15,13 @@ from linkml import LOCAL_METAMODEL_YAML_FILE from linkml.generators.shaclgen import ShaclGenerator -from linkml.utils.generator import Generator, config_mapping, parse_config_yaml, read_generator_config +from linkml.utils.generator import ( + Generator, + apply_config_defaults, + config_mapping, + parse_config_yaml, + read_generator_config, +) from linkml_runtime import SchemaView from linkml_runtime.linkml_model.meta import ( ClassDefinition, @@ -969,3 +976,136 @@ def test_generation_date_suppression_does_not_mutate_callers_schema(): assert schema.generation_date == "2020-01-01T00:00:00" assert gen.schema is not schema assert gen.schema.generation_date is None + + +# ------------------------------------------------------------------------------ +# apply_config_defaults: the overlay a CLI runs after read_generator_config so a +# config file fills only options the user did not set on the command line. +# Uses a minimal throwaway click command so the tests exercise the real click +# ParameterSource machinery, not a mock of it. +# ------------------------------------------------------------------------------ + + +def _run_with_config(config, cli_args=()): + """Invoke a throwaway click command, returning its resolved kwargs and the click result.""" + from click.testing import CliRunner + + captured: dict = {} + + @click.command() + @click.option("--foo") + @click.option("--bar/--no-bar", default=False) + @click.option("--baz", multiple=True) + @click.option("--path-opt", type=click.Path(path_type=Path)) + @click.option("--fmt", type=click.Choice(["a", "b"])) + @click.option("--verbose", "-v", count=True) + @click.version_option("1.0") + @click.pass_context + def runner_cmd(ctx, **kwargs): + apply_config_defaults(ctx, config, kwargs) + captured.update(kwargs) + + return captured, CliRunner().invoke(runner_cmd, list(cli_args), standalone_mode=False) + + +def _invoke_with_config(config, cli_args=()): + """Invoke the throwaway command, asserting it succeeded, and return its resolved kwargs.""" + captured, result = _run_with_config(config, cli_args) + assert result.exception is None, result.output + return captured + + +def test_apply_config_defaults_overlays_when_option_is_at_default(): + """Config value wins when the user did not pass the option -- the CLI/env/default + precedence chain, without which config-file support would do nothing.""" + kwargs = _invoke_with_config({"foo": "from-config"}) + assert kwargs["foo"] == "from-config" + + +def test_apply_config_defaults_command_line_beats_config(): + """A value explicitly passed on the command line is never overwritten by the + config; this is the precedence guarantee documented in --config-file's help.""" + kwargs = _invoke_with_config({"foo": "from-config"}, cli_args=["--foo", "from-cli"]) + assert kwargs["foo"] == "from-cli" + + +def test_apply_config_defaults_unknown_key_warns_per_key(caplog): + """Each unknown key gets its own warning so a config-file typo is individually + obvious, rather than being silently dropped or collapsed into a single message.""" + with caplog.at_level(logging.WARNING, logger="linkml.utils.generator"): + kwargs = _invoke_with_config({"typo_one": 1, "typo_two": 2, "foo": "ok"}) + messages = [rec.getMessage() for rec in caplog.records] + assert any("typo_one" in m for m in messages) + assert any("typo_two" in m for m in messages) + assert kwargs["foo"] == "ok" + + +def test_apply_config_defaults_skips_parse_time_callback_options(): + """--verbose etc. run click callbacks at parse time; a config value would be + stored but never trigger the callback, so those options are honored only from + the command line to avoid a silently-ineffective config setting.""" + kwargs = _invoke_with_config({"verbose": 3}) + assert kwargs["verbose"] == 0 + + +def test_apply_config_defaults_empty_config_is_noop(): + """An empty (or missing) generator section leaves args untouched -- the common + case for a shared config file that only names some generators. ``ctx`` is never + dereferenced on this path, so passing ``None`` would crash if it were.""" + args = {"foo": None, "bar": False} + assert apply_config_defaults(None, {}, args) is args + assert args == {"foo": None, "bar": False} + + +def test_apply_config_defaults_converts_values_with_the_option_type(): + """A config value is cast by its option's click type, so `path_opt: /tmp` reaches the + generator as a Path -- the same object the command line yields, not a raw str that + blows up later on the first Path method call.""" + kwargs = _invoke_with_config({"path_opt": "/tmp"}) + assert kwargs["path_opt"] == Path("/tmp") + + +def test_apply_config_defaults_rejects_a_value_the_option_type_refuses(): + """An invalid config value fails as a click usage error, exactly as it would on the + command line, instead of reaching the generator and crashing there.""" + _, result = _run_with_config({"fmt": "nope"}) + assert isinstance(result.exception, click.BadParameter) + + +@pytest.mark.parametrize( + ("value", "expected"), + [("one", ("one",)), (["one", "two"], ("one", "two"))], +) +def test_apply_config_defaults_wraps_scalars_for_repeatable_options(value, expected): + """A repeatable option gets a tuple whether the YAML supplied a scalar or a list. + Without the scalar wrap, click iterates the string per character and the generator + silently acts on 'o', 'n', 'e' instead of 'one'.""" + kwargs = _invoke_with_config({"baz": value}) + assert kwargs["baz"] == expected + + +def test_apply_config_defaults_ignores_options_click_never_passes(caplog): + """`--version` is expose_value=False, so click never hands it to the callback; naming + it in a config file is a typo like any other, and overlaying it would inject a kwarg + the generator's __init__ cannot accept.""" + with caplog.at_level(logging.WARNING, logger="linkml.utils.generator"): + kwargs = _invoke_with_config({"version": "1.2"}) + assert "version" not in kwargs + assert any("version" in rec.getMessage() for rec in caplog.records) + + +def test_apply_config_defaults_returns_the_same_dict(): + """The helper mutates and returns the same dict, so callers can chain or ignore + the return value; the type contract mirrors ``dict.update``.""" + from click.testing import CliRunner + + @click.command() + @click.option("--foo") + @click.pass_context + def cmd(ctx, **kwargs): + args = {} + result = apply_config_defaults(ctx, {"foo": "x"}, args) + assert result is args + assert args == {"foo": "x"} + + assert CliRunner().invoke(cmd, [], standalone_mode=False).exception is None diff --git a/tests/linkml/test_validator/test_shacl_validation_plugin.py b/tests/linkml/test_validator/test_shacl_validation_plugin.py index 19d7f5d9be..ba912166b0 100644 --- a/tests/linkml/test_validator/test_shacl_validation_plugin.py +++ b/tests/linkml/test_validator/test_shacl_validation_plugin.py @@ -11,6 +11,9 @@ # not re-exported, so that pyshacl stays an optional dependency. from linkml.validator.plugins.shacl_validation_plugin import ShaclValidationPlugin from linkml.validator.report import Severity +from linkml.validator.validation_context import ValidationContext +from linkml_runtime.linkml_model import SchemaDefinition +from linkml_runtime.loaders import yaml_loader def test_conforming_instance_yields_no_results(validation_context): @@ -87,3 +90,88 @@ def test_generated_shapes_are_cached(validation_context): list(plugin.process({"id": "P:1", "name": "One"}, validation_context)) assert len(plugin._loaded_graphs) == 1 + + +_RULES_SCHEMA_YAML = """ +id: https://example.org/plugin-rules +name: plugin_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/plugin-rules/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Code: + permissible_values: + alpha: + beta: +classes: + Base: + attributes: + id: + identifier: true + guard: {} + label: {} + color: + range: Color + code: + range: Code + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: x + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + color: + equals_string: Red + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + code: + equals_string_in: [alpha] + Child: + is_a: Base +""" + + +@pytest.mark.parametrize("target_class", ["Base", "Child"]) +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param({"guard": "g", "label": "x", "color": "Red", "code": "alpha"}, False, id="conforming"), + pytest.param({"guard": "g", "label": "y", "color": "Red", "code": "alpha"}, True, id="string"), + pytest.param({"guard": "g", "label": "x", "color": "Blue", "code": "alpha"}, True, id="enum-meaning"), + pytest.param({"guard": "g", "label": "x", "color": "Red", "code": "beta"}, True, id="enum-literal"), + pytest.param({"guard": "g", "color": "Red", "code": "alpha"}, True, id="target-absent"), + pytest.param({"label": "y", "color": "Blue", "code": "beta"}, False, id="unguarded"), + ], +) +def test_rule_violation_is_reported(target_class, instance, violates): + """Rules translated to ``sh:sparql`` are enforced through the plugin, whose + data graph comes from the LinkML RDF dumper, on instances of the declaring + class and of its subclasses alike.""" + context = ValidationContext(yaml_loader.loads(_RULES_SCHEMA_YAML, SchemaDefinition), target_class) + results = list(ShaclValidationPlugin().process({"id": "ex:x", **instance}, context)) + + assert all(result.severity is Severity.ERROR for result in results) + assert any("SPARQLConstraintComponent" in result.message for result in results) is violates diff --git a/tests/linkml/test_validator/test_validation_context.py b/tests/linkml/test_validator/test_validation_context.py index a674048952..e19c1bda48 100644 --- a/tests/linkml/test_validator/test_validation_context.py +++ b/tests/linkml/test_validator/test_validation_context.py @@ -647,9 +647,9 @@ def _schema_with_value_disallowed() -> SchemaDefinition: def test_cache_hit_preserves_value_disallowed_not_keyword(): - """A slot with value_presence=ABSENT produces a root-level `not` keyword. - Cache-hit must carry it through from defs_class so the validator rejects - instances that include the forbidden field.""" + """A slot with value_presence=ABSENT produces a root-level `not: {required}` + in `allOf`. Cache-hit must carry it through from defs_class so the + validator rejects instances that include the forbidden field.""" schema = _schema_with_value_disallowed() cold = ValidationContext(schema, "ForbidsX").json_schema_validator( @@ -661,8 +661,9 @@ def test_cache_hit_preserves_value_disallowed_not_keyword(): closed=False, include_range_class_descendants=False ) - assert "not" in cold.schema, cold.schema - assert "not" in warm.schema, "value_disallowed `not` keyword must survive cache-hit" + forbids = {"not": {"required": ["forbidden_field"]}} + assert forbids in cold.schema.get("allOf", []), cold.schema + assert forbids in warm.schema.get("allOf", []), "value_disallowed `not` keyword must survive cache-hit" # Instance includes the forbidden field -> both validators reject bad = {"forbidden_field": "anything", "other_field": "ok"} @@ -677,18 +678,19 @@ def test_cache_hit_preserves_value_disallowed_not_keyword(): def test_cache_hit_does_not_leak_value_disallowed_not_to_target_without(): - """Warming with ForbidsX places `not` at root in cached. Hitting cache for - Plain (which has no value_disallowed slots) must NOT inherit `not`.""" + """Warming with ForbidsX places `not: {required}` at root in cached. Hitting + cache for Plain (which has no value_disallowed slots) must NOT inherit it.""" schema = _schema_with_value_disallowed() cache_key = _make_cache_key(schema, include_range_class_descendants=False) ValidationContext(schema, "ForbidsX").json_schema_validator(closed=False, include_range_class_descendants=False) - assert "not" in _json_schema_cache[cache_key] + forbids = {"not": {"required": ["forbidden_field"]}} + assert forbids in _json_schema_cache[cache_key].get("allOf", []) validator = ValidationContext(schema, "Plain").json_schema_validator( closed=False, include_range_class_descendants=False ) - assert "not" not in validator.schema, validator.schema.get("not") + assert forbids not in validator.schema.get("allOf", []), validator.schema.get("allOf") # Plain has no constraint against `forbidden_field` — the leak would falsely # reject this. Confirm it doesn't. diff --git a/tests/linkml/utils/generator_agreement.py b/tests/linkml/utils/generator_agreement.py new file mode 100644 index 0000000000..92a885b0e8 --- /dev/null +++ b/tests/linkml/utils/generator_agreement.py @@ -0,0 +1,54 @@ +"""Validate the same instance against the JSON Schema and the SHACL shapes generated for a schema.""" + +import json +from typing import Any + +import yaml +from jsonschema import Draft201909Validator +from pyshacl import validate +from rdflib import Graph + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml_runtime import SchemaView + + +def as_json_ld(sv: SchemaView, obj: dict[str, Any], class_name: str) -> dict[str, Any]: + """*obj* with an ``@type`` on it and on each inlined object: the range of its slot, unless it states one.""" + class_name = obj.get("@type", class_name) + typed = {**obj, "@type": class_name} + for slot in sv.class_induced_slots(class_name): + value = obj.get(slot.name) + if slot.range in sv.all_classes() and value is not None: + values = [as_json_ld(sv, v, slot.range) for v in (value if isinstance(value, list) else [value])] + typed[slot.name] = values if isinstance(value, list) else values[0] + return typed + + +def without_types(obj: Any) -> Any: + """*obj* without its ``@type`` keys.""" + if isinstance(obj, dict): + return {k: without_types(v) for k, v in obj.items() if k != "@type"} + if isinstance(obj, list): + return [without_types(v) for v in obj] + return obj + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any], target: str) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of *target*. + + For SHACL, *instance* is loaded as RDF through the generated JSON-LD context, + and the shapes must pass meta-SHACL. + """ + text = yaml.safe_dump(schema) + json_schema = json.loads(JsonSchemaGenerator(text, top_class=target).serialize()) + context = json.loads(ContextGenerator(text).serialize())["@context"] + data = {"@context": context, **as_json_ld(SchemaView(text), instance, target)} + shapes = ShaclGenerator(text, closed=False).serialize() + conforms, _, _ = validate( + Graph().parse(data=json.dumps(data), format="json-ld"), + shacl_graph=Graph().parse(data=shapes, format="turtle"), + meta_shacl=True, + ) + return Draft201909Validator(json_schema).is_valid(without_types(instance)), conforms diff --git a/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py b/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py index 2df96c656a..c5ec8ebabc 100644 --- a/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py +++ b/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py @@ -9,7 +9,7 @@ from linkml_runtime import DataNotFoundError, MappingError from linkml_runtime.dumpers import rdflib_dumper, yaml_dumper -from linkml_runtime.linkml_model import Prefix +from linkml_runtime.linkml_model import Prefix, SchemaDefinition from linkml_runtime.loaders import rdflib_loader, yaml_loader from linkml_runtime.utils.schemaview import SchemaView from tests.linkml_runtime.test_loaders_dumpers import INPUT_DIR, OUTPUT_DIR @@ -33,6 +33,31 @@ logger = logging.getLogger(__name__) +@pytest.mark.parametrize("datatype", ["xsd:anyURI", "xs:anyURI", str(XSD.anyURI)]) +@pytest.mark.parametrize("parent, is_iri", [("uri", True), ("uriorcurie", True), ("string", False), ("curie", False)]) +def test_derived_uri_representation(datatype: str, parent: str, is_iri: bool) -> None: + """Type ancestry and expanded datatype jointly determine the serialized RDF term.""" + schema = SchemaView( + SchemaDefinition( + **{ + "id": "https://example.org/schema", + "name": "uri_representation", + "imports": ["linkml:types"], + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": "https://example.org/", "xs": str(XSD)}, + "types": {"Parent": {"typeof": parent, "uri": datatype}, "Child": {"typeof": "Parent"}}, + } + ) + ) + value = "ex:resource" + term = rdflib_dumper.inject_triples(value, schema, Graph(), "Child") + expected = URIRef("https://example.org/resource") if is_iri else Literal(value, datatype=XSD.anyURI) + assert term == expected + graph = Graph() + graph.add((URIRef("https://example.org/s"), URIRef("https://example.org/p"), term)) + reparsed = Graph().parse(data=graph.serialize(format="turtle"), format="turtle") + assert reparsed.value(URIRef("https://example.org/s"), URIRef("https://example.org/p")) == expected + + INPUT_PATH = Path(INPUT_DIR) OUTPUT_PATH = Path(OUTPUT_DIR) diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 63dbf095a1..f5e869fecc 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -1,17 +1,28 @@ """Tests for deterministic RDF serialization via pyoxigraph RDFC-1.0.""" +import difflib +import json +import logging import os import re import subprocess import sys import textwrap +import warnings +from collections.abc import Callable +from pathlib import Path import pyoxigraph as ox import pytest import rdflib -from rdflib import BNode, Graph, Literal, URIRef -from rdflib.namespace import RDF - +from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib.namespace import OWL, RDF, RDFS, SH, XSD + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.rdfgen import RDFGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml.generators.shexgen import ShExGenerator +from linkml.utils.generator import Generator from linkml_runtime.utils import rdf_canonicalize as rdf_canon_mod from linkml_runtime.utils.rdf_canonicalize import ( RDFCanonicalizationWarning, @@ -304,13 +315,13 @@ def fake_serialize(*args, **kwargs): raise ValueError("Invalid prefix bad IRI 'http://example.com/ has space/', Invalid IRI code point ' '") return real_serialize(*args, **kwargs) - monkeypatch.setattr(rdf_canon_mod.ox, "serialize", fake_serialize) + monkeypatch.setattr(ox, "serialize", fake_serialize) g = Graph() g.bind("ex", "http://example.com/") g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) - with pytest.warns(RDFCanonicalizationWarning, match="rejected one or more prefix IRIs"): + with pytest.warns(RDFCanonicalizationWarning, match="rejected the prefix or base IRIs"): ttl = canonicalize_rdf_graph(g, output_format="turtle") # Fallback retry should succeed and the output still round-trips. g2 = Graph() @@ -319,22 +330,74 @@ def fake_serialize(*args, **kwargs): def test_unsupported_format_warns_and_falls_back(): - """A format pyoxigraph doesn't know about falls back to rdflib with a warning.""" + """A format the canonicalizer doesn't know about falls back to rdflib with a warning. + + ``longturtle`` is an rdflib serializer plugin with no pyoxigraph + equivalent, so it can only be produced by delegating to rdflib, and + rdflib orders that output by graph traversal. The warning is what tells + the caller the determinism guarantee does not extend to this format. + """ g = Graph() g.bind("ex", "http://example.com/") g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) - with pytest.warns(RDFCanonicalizationWarning, match="does not support format"): - result = canonicalize_rdf_graph(g, output_format="json-ld") + with pytest.warns(RDFCanonicalizationWarning, match="not one of the formats"): + result = canonicalize_rdf_graph(g, output_format="longturtle") # The fallback should still produce valid output in the requested format. assert result +def test_json_ld_is_canonicalized_rather_than_handed_to_rdflib(): + """``json-ld`` gets the determinism guarantee, not a fallback warning. + + rdflib's JSON-LD serializer emits objects in traversal order, so this used + to be an unsupported format that warned and returned whatever rdflib felt + like. It is now produced deterministically, which is why no warning is due. + """ + g = _make_graph_with_bnodes() + with warnings.catch_warnings(): + warnings.simplefilter("error", RDFCanonicalizationWarning) + result = canonicalize_rdf_graph(g, output_format="json-ld") + assert json.loads(result) + + +def test_a_rejected_base_iri_is_recovered_rather_than_fatal(monkeypatch): + """A base pyoxigraph refuses costs the base directive, not the document. + + rdflib accepts a relative or otherwise unusable ``base``, and pyoxigraph + then refuses it at serialization time. Dropping the base and retrying + yields a correct document; failing the whole call over a directive that is + optional in every output format does not. + """ + real_serialize = ox.serialize + calls = {"n": 0} + + def fake_serialize(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise ValueError("Invalid base IRI 'broken', Invalid IRI code point ' '") + return real_serialize(*args, **kwargs) + + monkeypatch.setattr(ox, "serialize", fake_serialize) + + g = Graph() + g.bind("ex", "http://example.com/") + g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) + + with pytest.warns(RDFCanonicalizationWarning, match="rejected the prefix or base IRIs"): + ttl = canonicalize_rdf_graph(g, output_format="turtle") + + g2 = Graph() + g2.parse(data=ttl, format="turtle") + assert rdflib.compare.isomorphic(g, g2) + + def test_unrelated_value_error_propagates(monkeypatch): - """Any ``ValueError`` whose message doesn't begin with ``Invalid prefix`` is re-raised. + """A ``ValueError`` that is not about prefixes or the base must be re-raised. - Guards against future regressions where a pyoxigraph serializer bug - raises a different ``ValueError`` and gets silently swallowed by the - invalid-prefix fallback path. + The retry-without-prefixes path exists for two specific pyoxigraph + complaints. Widening it to every ``ValueError`` would let a serializer bug + be papered over by a retry that happens to succeed, and the caller would + get a plausible-looking document with no indication anything went wrong. """ real_serialize = ox.serialize calls = {"count": 0} @@ -343,16 +406,16 @@ def fake_serialize(*args, **kwargs): calls["count"] += 1 # First call is the prefixed serialize — raise an unrelated ValueError. if calls["count"] == 1: - raise ValueError("Invalid base IRI 'broken', Invalid IRI code point ' '") + raise ValueError("BUG: serializer state corrupted") return real_serialize(*args, **kwargs) - monkeypatch.setattr(rdf_canon_mod.ox, "serialize", fake_serialize) + monkeypatch.setattr(ox, "serialize", fake_serialize) g = Graph() g.bind("ex", "http://example.com/") g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) - with pytest.raises(ValueError, match="Invalid base IRI"): + with pytest.raises(ValueError, match="BUG: serializer state corrupted"): canonicalize_rdf_graph(g, output_format="turtle") @@ -455,7 +518,7 @@ def test_fallback_preserves_relative_iri(): assert "" in result -@pytest.mark.parametrize("output_format", ["turtle", "nt"]) +@pytest.mark.parametrize("output_format", ["turtle", "xml"]) def test_fallback_is_deterministic_across_processes(output_format): """The rdflib fallback produces byte-identical output across processes. @@ -463,9 +526,15 @@ def test_fallback_is_deterministic_across_processes(output_format): parse to fail, exercising the fallback path. The graph also contains several blank nodes: a plain ``graph.serialize()`` would label them non-deterministically, so this test would fail without the blank-node - canonicalization in ``_deterministic_fallback_serialize``. Two - subprocesses with different ``PYTHONHASHSEED`` values must agree byte for - byte. + canonicalization in the fallback. Two subprocesses with different + ``PYTHONHASHSEED`` values must agree byte for byte. + + ``nt`` is not covered here because a fallback graph is by definition one + pyoxigraph refused, and for this graph the reason is a relative IRI, which + N-Triples forbids outright (N-Triples 1.1 §2.2). There is no deterministic + N-Triples document to produce, so the correct answer is a refusal -- + asserted by ``test_nt_fallback_refuses_rather_than_writing_an_unparseable_file`` + below. """ program = textwrap.dedent( f""" @@ -507,3 +576,350 @@ def run(seed: str) -> str: assert out_a == out_b, ( "Fallback output differs across PYTHONHASHSEED values; blank-node canonicalization may be missing" ) + + +def test_nt_fallback_refuses_rather_than_writing_an_unparseable_file(): + """N-Triples output for a graph with a relative IRI must raise, not lie. + + rdflib's N-Triples serializer reuses Turtle's term rendering and does not + enforce the absolute-IRI rule, so asking it for ``nt`` here yields a file + containing ```` that its own parser then rejects. N-Triples 1.1 + §2.2 permits only absolute IRIs, so no valid document exists for this + graph and a refusal is the only honest answer. Writing the file instead + defers the failure to whoever tries to read it. + """ + g = Graph() + g.bind("ex", "http://example.com/") + g.add((URIRef("http://example.com/s"), URIRef("http://purl.org/ontology/bibo/status"), URIRef("testing"))) + + with pytest.raises(ValueError, match="not an absolute IRI"): + canonicalize_rdf_graph(g, output_format="nt") + + +def _shapes_graph(count: int, extra: bool = False) -> Graph: + """A graph shaped like generator output: one blank node per named subject.""" + g = Graph() + g.bind("ex", "http://example.com/") + names = [f"{i:02d}" for i in range(count)] + (["AAAinserted"] if extra else []) + for name in names: + subject = URIRef(f"http://example.com/Shape{name}") + prop = BNode() + g.add((subject, URIRef("http://example.com/property"), prop)) + g.add((prop, URIRef("http://example.com/path"), URIRef(f"http://example.com/p{name}"))) + return g + + +def _changed_line_count(before: str, after: str) -> int: + """Count added and removed content lines in a unified diff.""" + + diff = difflib.unified_diff(before.splitlines(), after.splitlines(), n=0, lineterm="") + return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) + + +def test_diff_stable_without_the_extra_names_the_extra(mock_missing_import) -> None: + """Requesting the opt-in path without diffable-rdf says how to install it.""" + mock_missing_import("diffable_rdf") + + with pytest.raises(ImportError, match=r"linkml-runtime\[diff-stable\]"): + canonicalize_rdf_graph(_make_graph_with_bnodes(), diff_stable=True) + + +def test_default_serialization_without_the_library_names_the_dependency( + mock_missing_import: Callable[[str], None], +) -> None: + """Default serialization also explains how to repair a missing library installation.""" + mock_missing_import("diffable_rdf") + + with pytest.raises(ImportError, match="diffable-rdf is required for RDF serialization") as error: + canonicalize_rdf_graph(_make_graph_with_bnodes()) + + assert "pip install 'linkml-runtime[diff-stable]'" in str(error.value) + assert isinstance(error.value.__cause__, ImportError) + + +@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) +@pytest.mark.parametrize("output_format", ["nt", "turtle"]) +def test_diff_stable_preserves_semantics(diff_stable: bool, output_format: str) -> None: + """Each serialization preserves the source graph under blank-node renaming.""" + graph = _make_graph_with_bnodes() + output = canonicalize_rdf_graph(graph, output_format=output_format, diff_stable=diff_stable) + parsed = Graph().parse(data=output, format=output_format) + assert len(parsed) == len(graph) + assert rdflib.compare.isomorphic(graph, parsed) + + +@pytest.mark.diffable_rdf +def test_diff_stable_is_deterministic() -> None: + """A cycle's tied signatures remain independent of input blank-node IDs.""" + graphs = [] + predicate = URIRef("http://example.com/next") + for labels, reverse in [("abcd", False), ("dbac", True)]: + nodes = [BNode(label) for label in labels] + triples = [(node, predicate, nodes[(i + 1) % len(nodes)]) for i, node in enumerate(nodes)] + graph = Graph() + for triple in reversed(triples) if reverse else triples: + graph.add(triple) + graphs.append(graph) + + assert rdflib.compare.isomorphic(*graphs) + outputs = [canonicalize_rdf_graph(graph, output_format="nt", diff_stable=True) for graph in graphs] + assert outputs[0] == outputs[1] + for graph, output in zip(graphs, outputs): + assert rdflib.compare.isomorphic(graph, Graph().parse(data=output, format="nt")) + + +@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) +def test_diff_stable_insertion_labels(diff_stable: bool) -> None: + """An independent insertion renumbers RDFC labels but preserves the old WL label.""" + before = Graph().parse( + data="""@prefix ex: . + ex:Shape00 ex:property _:old . + _:old ex:path ex:p00 . + """, + format="turtle", + ) + after = Graph() + for triple in before: + after.add(triple) + inserted = BNode() + ex = Namespace("http://example.com/") + after.add((ex.ShapeAAAinserted, ex.property, inserted)) + after.add((inserted, ex.path, ex.pAAAinserted)) + + outputs = [canonicalize_rdf_graph(graph, output_format="nt", diff_stable=diff_stable) for graph in (before, after)] + parsed = [set(ox.parse(output, format=ox.RdfFormat.N_TRIPLES)) for output in outputs] + old_subject = ox.NamedNode(str(ex.Shape00)) + new_subject = ox.NamedNode(str(ex.ShapeAAAinserted)) + property_iri = ox.NamedNode(str(ex.property)) + path_iri = ox.NamedNode(str(ex.path)) + (old_before,) = {q.object for q in parsed[0] if q.subject == old_subject and q.predicate == property_iri} + (old_after,) = {q.object for q in parsed[1] if q.subject == old_subject and q.predicate == property_iri} + (new_after,) = {q.object for q in parsed[1] if q.subject == new_subject and q.predicate == property_iri} + assert all(isinstance(node, ox.BlankNode) for node in (old_before, old_after, new_after)) + assert old_after != new_after + inserted_quads = { + ox.Quad(new_subject, property_iri, new_after), + ox.Quad(new_after, path_iri, ox.NamedNode(str(ex.pAAAinserted))), + } + assert parsed[0] == { + ox.Quad(old_subject, property_iri, old_before), + ox.Quad(old_before, path_iri, ox.NamedNode(str(ex.p00))), + } + assert ( + parsed[1] + == { + ox.Quad(old_subject, property_iri, old_after), + ox.Quad(old_after, path_iri, ox.NamedNode(str(ex.p00))), + } + | inserted_quads + ) + + if diff_stable: + assert all(re.fullmatch(_STABLE_LABEL, node.value) for node in (old_before, old_after, new_after)) + assert old_before == old_after + assert parsed[1] - parsed[0] == inserted_quads + assert parsed[0] - parsed[1] == set() + else: + assert (old_before.value, old_after.value, new_after.value) == ("c14n0", "c14n1", "c14n0") + for graph, output in zip((before, after), outputs): + assert rdflib.compare.isomorphic(graph, Graph().parse(data=output, format="nt")) + + +@pytest.mark.diffable_rdf +def test_diff_stable_confines_an_insertion_to_the_lines_it_touches() -> None: + """Diff-stable labels reduce Turtle line churn for independent property nodes.""" + before, after = _shapes_graph(20), _shapes_graph(20, extra=True) + baseline = _changed_line_count(canonicalize_rdf_graph(before), canonicalize_rdf_graph(after)) + stable = _changed_line_count( + canonicalize_rdf_graph(before, diff_stable=True), + canonicalize_rdf_graph(after, diff_stable=True), + ) + assert stable < baseline / 4, f"expected diff-stable output to churn far less; got {stable} vs baseline {baseline}" + + +_STABLE_LABEL = r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?" + + +_DIFF_STABLE_SCHEMA = """\ +id: https://example.org/diffstable +name: diffstable +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/diffstable/ +default_prefix: ex +default_range: string +imports: + - linkml:types +classes: + Person: + slots: [name, knows] + Organization: + slots: [name] +slots: + name: + range: string + knows: + range: Person + multivalued: true +""" + + +@pytest.fixture +def diff_stable_schema(tmp_path: Path) -> Path: + """Write the small schema for real generator calls.""" + schema = tmp_path / "schema.yaml" + schema.write_text(_DIFF_STABLE_SCHEMA, encoding="utf-8") + return schema + + +@pytest.mark.diffable_rdf +@pytest.mark.parametrize( + ("generator", "kwargs"), + [ + pytest.param(OwlSchemaGenerator, {}, id="owlgen"), + pytest.param(RDFGenerator, {}, id="rdfgen", marks=pytest.mark.network), + pytest.param(ShaclGenerator, {}, id="shaclgen"), + pytest.param(ShExGenerator, {"format": "rdf"}, id="shexgen", marks=pytest.mark.network), + ], +) +def test_diff_stable_reaches_every_rdf_generator( + diff_stable_schema: Path, generator: type[Generator], kwargs: dict[str, str] +) -> None: + """Every RDF generator emits the designed schema with diff-stable labels.""" + output = generator(str(diff_stable_schema), diff_stable=True, **kwargs).serialize() + quads = list(ox.parse(output, format=ox.RdfFormat.TURTLE)) + blank_nodes = {term for quad in quads for term in (quad.subject, quad.object) if isinstance(term, ox.BlankNode)} + assert blank_nodes + assert all(re.fullmatch(_STABLE_LABEL, node.value) for node in blank_nodes) + graph = Graph().parse(data=output, format="turtle") + ex = Namespace("https://example.org/diffstable/") + if generator is OwlSchemaGenerator: + assert (ex.Person, RDF.type, OWL.Class) in graph + assert (ex.Organization, RDF.type, OWL.Class) in graph + (restriction,) = { + node + for node in graph.objects(ex.Person, RDFS.subClassOf) + if (node, OWL.onProperty, ex.knows) in graph and (node, OWL.allValuesFrom, ex.Person) in graph + } + assert isinstance(restriction, BNode) + assert (restriction, RDF.type, OWL.Restriction) in graph + assert (restriction, OWL.allValuesFrom, ex.Person) in graph + elif generator is ShaclGenerator: + for subject, paths in [(ex.Person, {ex.name, ex.knows}), (ex.Organization, {ex.name})]: + assert (subject, RDF.type, SH.NodeShape) in graph + assert (subject, SH.targetClass, subject) in graph + properties = set(graph.objects(subject, SH.property)) + assert {graph.value(prop, SH.path) for prop in properties} == paths + (ignored,) = graph.objects(subject, SH.ignoredProperties) + assert set(graph.predicate_objects(ignored)) == {(RDF.first, RDF.type), (RDF.rest, RDF.nil)} + (knows,) = {node for node in graph.objects(ex.Person, SH.property) if (node, SH.path, ex.knows) in graph} + assert (knows, SH["class"], ex.Person) in graph + elif generator is RDFGenerator: + linkml = Namespace("https://w3id.org/linkml/") + schema = ex.diffstable + assert (schema, RDF.type, linkml.SchemaDefinition) in graph + assert set(graph.objects(schema, linkml.classes)) == {ex.Person, ex.Organization} + (prefix,) = {node for node in graph.objects(schema, SH.declare) if (node, SH.prefix, Literal("ex")) in graph} + assert (prefix, SH.namespace, Literal(str(ex), datatype=XSD.anyURI)) in graph + else: + assert generator is ShExGenerator + shex = Namespace("http://www.w3.org/ns/shex#") + assert (ex.Person, RDF.type, shex.Shape) in graph + (expression,) = graph.objects(ex.Person, shex.expression) + assert (expression, RDF.type, shex.EachOf) in graph + (expressions,) = graph.objects(expression, shex.expressions) + assert ex.Person_tes in set(graph.items(expressions)) + assert (ex.Person_tes, RDF.type, shex.EachOf) in graph + (slots,) = graph.objects(ex.Person_tes, shex.expressions) + (knows,) = {node for node in graph.items(slots) if (node, shex.predicate, ex.knows) in graph} + assert (knows, RDF.type, shex.TripleConstraint) in graph + assert (knows, shex.valueExpr, ex.Person) in graph + assert (knows, shex.min, Literal(0)) in graph + assert (knows, shex.max, Literal(-1)) in graph + + +@pytest.mark.diffable_rdf +def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback() -> None: + """A relative IRI requires the fallback and a warning that labels cannot apply.""" + graph = _make_graph_with_bnodes() + graph.add((URIRef("testing"), URIRef("http://example.com/p"), Literal("v"))) + with pytest.warns(RDFCanonicalizationWarning, match="not diff-stable"): + stable = canonicalize_rdf_graph(graph, diff_stable=True) + with pytest.warns(RDFCanonicalizationWarning): + plain = canonicalize_rdf_graph(graph, diff_stable=False) + assert stable == plain + + +# -------------------------------------------------------------------------- +# The bridge from the library's logging to linkml's warnings +# -------------------------------------------------------------------------- + + +def _fallback_graph() -> Graph: + """A graph pyoxigraph refuses, so every call takes a degraded path.""" + g = Graph() + g.bind("ex", "http://example.com/") + g.add((URIRef("http://example.com/s"), URIRef("http://purl.org/ontology/bibo/status"), URIRef("testing"))) + return g + + +def test_degraded_path_warning_points_at_the_caller(): + """The warning must name the line that asked for the serialization. + + ``diffable-rdf`` reports degradation through ``logging``; linkml re-emits + it through ``warnings`` so it is visible without logging configuration. + A re-emitted warning is only actionable if it is attributed to the caller + rather than to the adapter, and the number of frames in between is not + something a reader can eyeball -- hence this test. + """ + graph = _fallback_graph() + + with pytest.warns(RDFCanonicalizationWarning) as caught: + canonicalize_rdf_graph(graph, output_format="turtle") # attribution target + + assert caught[0].filename == __file__ + source = Path(caught[0].filename).read_text(encoding="utf-8").splitlines() + assert "# attribution target" in source[caught[0].lineno - 1] + + +def test_bridging_does_not_leave_the_library_logger_modified(): + """Raising the library's log level to capture records must not be permanent. + + The adapter has to enable ``WARNING`` on the ``diffable_rdf`` logger to see + anything, which is a global mutation. Leaving it raised would silently + change logging behaviour for the rest of the process. + """ + logger = logging.getLogger("diffable_rdf") + level_before = logger.level + handlers_before = list(logger.handlers) + + with pytest.warns(RDFCanonicalizationWarning): + canonicalize_rdf_graph(_fallback_graph(), output_format="turtle") + + assert logger.level == level_before + assert logger.handlers == handlers_before + + +def test_a_caller_who_configured_logging_still_receives_the_record(caplog): + """Re-emitting as a warning must not steal the record from a log handler. + + An application that deliberately configured the ``diffable_rdf`` logger is + asking for these records. The adapter adds a mechanism; it does not get to + remove one. + """ + with caplog.at_level(logging.WARNING, logger="diffable_rdf"), pytest.warns(RDFCanonicalizationWarning): + canonicalize_rdf_graph(_fallback_graph(), output_format="turtle") + + assert any("non-standard RDF" in record.getMessage() for record in caplog.records) + + +def test_warnings_survive_an_exception_from_the_library(): + """Degradation reported before a failure must still reach the caller. + + ``nt`` output for this graph degrades to rdflib and *then* refuses, because + N-Triples has no way to write the relative IRI. Dropping the warning + because the call ended in an exception would hide the first half of the + story, which is the half that explains the second. + """ + with pytest.warns(RDFCanonicalizationWarning, match="non-standard RDF"), pytest.raises(ValueError): + canonicalize_rdf_graph(_fallback_graph(), output_format="nt") diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py new file mode 100644 index 0000000000..ffca5ae447 --- /dev/null +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py @@ -0,0 +1,314 @@ +"""Correctness properties shared by linkml's RDF canonicalizer and the library behind it. + +``linkml_runtime.utils.rdf_canonicalize`` and +``diffable_rdf.canonicalize_rdf_graph`` are the same code: the module was +extracted into a standalone library at the maintainers' request +(linkml/linkml#3295). The two copies then diverged for a while, and this file +is what made the divergence visible -- each test asserts one correctness +property against *both* implementations, so a fix that landed on one side and +not the other showed up as a failure rather than as nothing at all. + +Nine properties held only in the library, two of them cases of silent data +corruption where the output parsed cleanly and said something the input never +said. One held only in linkml: the library dropped ``@base`` on the degraded +path. Asserting both directions is what got each of them fixed where it +belonged -- the ``@base`` gap in diffable-rdf 0.4.0, the other nine in linkml +by deleting the in-tree copy and calling the library. + +Every case is now unmarked, which is the point: the file is the evidence that +the delegation changed no behaviour it should not have, and it keeps running +against both entry points so that a future divergence fails the suite instead +of going unnoticed. +""" + +import os +import subprocess +import sys +import textwrap +import warnings + +import diffable_rdf +import pytest +import rdflib +from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib.namespace import RDF + +from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph as linkml_canonicalize + +EX = Namespace("http://example.org/") + +_IMPLEMENTATIONS = ( + (linkml_canonicalize, "linkml"), + (diffable_rdf.canonicalize_rdf_graph, "diffable-rdf"), +) + + +def _impls(): + """Parametrize a test over both entry points. + + linkml's is a thin adapter over the library's, so the two agree by + construction today. Running both anyway is what turns a future re-fork, or + an adapter that quietly changes behaviour on the way through, into a test + failure. + """ + return [pytest.param(fn, id=ident) for fn, ident in _IMPLEMENTATIONS] + + +def _apply(fn, graph, output_format="turtle"): + """Call an implementation, ignoring the warnings both emit on the fallback path.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return fn(graph, output_format) + + +# -------------------------------------------------------------------------- +# Silent data corruption +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_base_with_a_fragment_does_not_rewrite_every_iri(canonicalize): + """A base IRI ending in ``#`` must not change the graph's terms. + + Relativizing ``http://ex.org/d#a`` against base ``http://ex.org/d#`` gives + ``<#a>``, which is correct per RFC 3986 -- but rdflib's parser resolves a + fragment reference by concatenation and reads it back as + ``http://ex.org/d##a``. Every term of the graph silently changes, and the + output parses cleanly, so nothing reports it. + """ + graph = Graph(base="http://ex.org/d#") + graph.add((URIRef("http://ex.org/d#a"), EX.p, Literal("v"))) + + round_tripped = Graph() + round_tripped.parse(data=_apply(canonicalize, graph), format="turtle") + + assert {str(s) for s in round_tripped.subjects()} == {"http://ex.org/d#a"} + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_a_shared_rdf_list_tail_is_not_duplicated(canonicalize): + """Two lists sharing a tail must not gain triples on the way out. + + rdflib's Turtle writer renders ``( ... )`` collection syntax per list, so a + tail referenced from two lists is written twice as two separate blank + nodes. The result asserts more than the input did. + """ + graph = Graph() + graph.bind("ex", EX) + tail = BNode() + graph.add((tail, RDF.first, Literal("shared"))) + graph.add((tail, RDF.rest, RDF.nil)) + for name in ("l1", "l2"): + head = BNode() + graph.add((EX[name], EX.list, head)) + graph.add((head, RDF.first, Literal(name))) + graph.add((head, RDF.rest, tail)) + # A relative IRI is non-standard RDF, so pyoxigraph refuses the graph and + # both implementations degrade to rdflib -- the path this defect lives on. + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + round_tripped = Graph() + round_tripped.parse(data=_apply(canonicalize, graph), format="turtle") + + assert len(round_tripped) == len(graph) + + +# -------------------------------------------------------------------------- +# Output that no parser will read +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_nt_output_either_parses_or_refuses(canonicalize): + """N-Triples admits only absolute IRIs, so a relative one must not be written. + + The fallback is taken *because* the graph holds a term pyoxigraph rejected. + Writing it out anyway produces a file that fails on line 1. Refusing with a + message naming the term and a format that can carry the graph is the fix; + returning unreadable text is not. + """ + graph = Graph() + graph.add((URIRef("testing"), EX.p, Literal("v"))) + + try: + serialized = _apply(canonicalize, graph, "nt") + except ValueError: + return # refused, with an explanation -- the correct outcome + + rdflib.Graph().parse(data=serialized, format="nt") + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_a_unicode_line_separator_in_a_literal_survives(canonicalize): + """Sorting N-Triples lines must split on newlines only. + + N-Triples permits U+2028 raw inside a quoted literal, but + ``str.splitlines()`` breaks on it as well as on U+2029, U+0085, U+000B, + U+000C and U+001C-1E. One statement becomes two lines, the halves sort + independently, the separator is rewritten as a newline, and the document + no longer parses. + """ + graph = Graph() + graph.add((EX.s, EX.p, Literal("a\u2028b"))) + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + try: + serialized = _apply(canonicalize, graph, "nt") + except ValueError: + return # refused because of the relative IRI, before the sort + + assert "\u2028" in serialized + rdflib.Graph().parse(data=serialized, format="nt") + + +# -------------------------------------------------------------------------- +# Non-determinism, which is the property the module exists to provide +# -------------------------------------------------------------------------- + +_ACROSS_PROCESSES = textwrap.dedent( + """ + import sys, warnings + warnings.simplefilter("ignore") + from rdflib import BNode, Graph, Literal, Namespace, URIRef + from rdflib.namespace import RDF + + module, output_format, force_fallback = sys.argv[1], sys.argv[2], sys.argv[3] == "1" + if module == "linkml": + from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + else: + from diffable_rdf import canonicalize_rdf_graph + + EX = Namespace("http://example.org/") + g = Graph() + if force_fallback: + g.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + for i in range(12): + b = BNode() + g.add((EX["s%02d" % i], EX.child, b)) + g.add((b, EX.k, Literal("v%d" % i))) + g.add((b, RDF.type, EX.T)) + for host in "abcdefgh": + g.add((URIRef("http://%s.example/s" % host), + URIRef("http://%s.example/p" % host), + URIRef("http://%s.example/o" % host))) + sys.stdout.write(canonicalize_rdf_graph(g, output_format)) + """ +) + + +def _outputs_across_processes(module, output_format, force_fallback): + """Serialize the same graph in four processes with different hash seeds.""" + outputs = set() + for seed in ("0", "1", "12345", "999"): + completed = subprocess.run( + [sys.executable, "-c", _ACROSS_PROCESSES, module, output_format, "1" if force_fallback else "0"], + capture_output=True, + text=True, + encoding="utf-8", + env={**os.environ, "PYTHONHASHSEED": seed}, + check=False, + ) + if completed.returncode != 0: + pytest.fail(f"{module}/{output_format} failed:\n{completed.stderr[-2000:]}") + outputs.add(completed.stdout) + return outputs + + +_XML_TRAVERSAL = "degraded RDF/XML is ordered by rdflib's graph traversal, which follows set iteration order" +_NS_NAMES = "auto-generated ns1/ns2 prefix names are allocated in traversal order" +_JSONLD_UNMAPPED = "json-ld is absent from the format map, so it falls through to rdflib with no determinism guarantee" + + +@pytest.mark.parametrize( + ("module", "output_format", "force_fallback"), + [ + pytest.param("linkml", "xml", True, id="linkml-xml-fallback"), + pytest.param("diffable_rdf", "xml", True, id="diffable-rdf-xml-fallback"), + pytest.param("linkml", "turtle", True, id="linkml-turtle-fallback"), + pytest.param("diffable_rdf", "turtle", True, id="diffable-rdf-turtle-fallback"), + pytest.param("linkml", "json-ld", False, id="linkml-jsonld"), + pytest.param("diffable_rdf", "json-ld", False, id="diffable-rdf-jsonld"), + ], +) +def test_output_is_byte_identical_across_processes(module, output_format, force_fallback): + """The same graph must serialize to the same bytes in any process. + + Three separate causes used to break this in the in-tree copy, and all three + are fixed by delegating. Degraded RDF/XML was ordered by rdflib's own graph + traversal, which follows set iteration order. Auto-generated ``ns1``/``ns2`` + prefix names were allocated in traversal order too, so the *names* moved + even when the triples did not. And ``json-ld`` was not in the format map at + all, so it fell through to rdflib's serializer, which carries no + determinism guarantee. + """ + assert len(_outputs_across_processes(module, output_format, force_fallback)) == 1 + + +# -------------------------------------------------------------------------- +# Interface +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_every_format_ends_with_exactly_one_newline(canonicalize): + """A committed artifact should not depend on which format wrote it. + + Passing through whatever the serializer produced gives no trailing newline + for RDF/XML and one for Turtle, so POSIX text tools disagree about whether + the file has a last line. + """ + counts = {} + for output_format in ("turtle", "nt", "xml", "trig", "n3"): + graph = Graph() + graph.bind("ex", EX) + graph.add((EX.s, EX.p, Literal("v"))) + serialized = _apply(canonicalize, graph, output_format) + counts[output_format] = len(serialized) - len(serialized.rstrip("\n")) + + assert counts == dict.fromkeys(counts, 1) + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_a_dataset_is_refused_rather_than_partly_serialized(canonicalize): + """A Dataset is a Graph subclass, so it is accepted and quietly flattened. + + Every named graph collapses into one document and the graph names are + dropped, which is a different dataset. The caller asked for something this + function cannot express and should be told so. + """ + dataset = rdflib.Dataset() + dataset.graph(URIRef("http://ex/g1")).add((EX.a, EX.p, Literal("in-g1"))) + dataset.graph(URIRef("http://ex/g2")).add((EX.b, EX.p, Literal("in-g2"))) + + with pytest.raises(TypeError): + _apply(canonicalize, dataset) + + +# -------------------------------------------------------------------------- +# A property both implementations now hold +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_base_survives_the_degraded_path(canonicalize): + """``@base`` must not disappear just because pyoxigraph refused the graph. + + ``rdflib_dumper.dumps(..., prefix_map={"@base": ...})`` is a supported way + to ask for a document whose IRIs are written relative to a base, and the + metamodel routinely produces graphs that fall back (a bare ``status: + testing`` on a ``uriorcurie`` slot serializes to the relative ````, + which pyoxigraph rejects). Losing the directive on exactly that path means + the feature works only for graphs that never needed the fallback. + + This ran the other way until diffable-rdf 0.4.0. It was the last property + blocking delegation, because linkml held it and the library did not, so + adopting the library would have been a regression. Fixing it there rather + than keeping the in-tree copy alive is what let the other nine gaps close + at once. + """ + graph = Graph(base="http://example.org/default/") + graph.bind("ex", EX) + graph.add((EX.s, EX.p, Literal("v"))) + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + assert "@base ." in _apply(canonicalize, graph) diff --git a/uv.lock b/uv.lock index 89ec70cfc5..397f49e12a 100644 --- a/uv.lock +++ b/uv.lock @@ -652,7 +652,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -725,7 +725,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1075,6 +1075,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "diffable-rdf" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyoxigraph" }, + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/df/1d71c0c984eac1cfd25531aabf84528941b01083c875da49d44882d1b5af/diffable_rdf-0.4.0.tar.gz", hash = "sha256:a84afaa10332e6a039b6ddcabf76bced3049e4da467c88ee786d0fa35218111e", size = 838040, upload-time = "2026-09-11T11:43:12.844Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/49/b62ee864fe6769b759f04e3709fd79711b1cdf49223b1bb0e985e507ead3/diffable_rdf-0.4.0-py3-none-any.whl", hash = "sha256:c59c57429465f786fdbc8d38ddbb687da16748c5752f0c2ac4a77b5527aa9aed", size = 46126, upload-time = "2026-09-11T11:43:11.536Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -1163,7 +1176,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1706,17 +1719,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } wheels = [ @@ -1734,17 +1747,17 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/34/29b18c62e39ee2f7a6a3bba7efd952729d8aadd45ca17efc34453b717665/ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731", size = 4396932, upload-time = "2025-09-29T10:55:53.948Z" } wheels = [ @@ -1765,7 +1778,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2395,6 +2408,11 @@ dependencies = [ { name = "watchdog" }, ] +[package.optional-dependencies] +diff-stable = [ + { name = "linkml-runtime", extra = ["diff-stable"] }, +] + [package.dev-dependencies] bigquery = [ { name = "sqlalchemy-bigquery" }, @@ -2409,6 +2427,7 @@ dev = [ { name = "ipython-genutils" }, { name = "jsonpatch" }, { name = "jupyter" }, + { name = "linkml-runtime", extra = ["diff-stable"] }, { name = "mock" }, { name = "myst-nb" }, { name = "nbconvert" }, @@ -2461,6 +2480,7 @@ shacl = [ tests = [ { name = "black" }, { name = "duckdb" }, + { name = "linkml-runtime", extra = ["diff-stable"] }, { name = "numpydantic" }, { name = "pyshacl" }, { name = "sqlalchemy-bigquery" }, @@ -2500,12 +2520,13 @@ requires-dist = [ { name = "jsonasobj2", specifier = ">=1.0.3,<2.0.0" }, { name = "jsonschema", extras = ["format"], specifier = ">=4.26.0" }, { name = "linkml-runtime", editable = "packages/linkml_runtime" }, + { name = "linkml-runtime", extras = ["diff-stable"], marker = "extra == 'diff-stable'", editable = "packages/linkml_runtime" }, { name = "openapi-spec-validator", specifier = ">=0.8.4" }, { name = "openpyxl" }, { name = "parse" }, { name = "platformdirs", specifier = ">=4.12.1" }, { name = "prefixcommons", specifier = ">=0.1.7" }, - { name = "prefixmaps", specifier = ">=0.2.2" }, + { name = "prefixmaps", git = "https://github.com/linkml/prefixmaps?rev=75435150a1b31760b9780af2b64a265943a9b263" }, { name = "pydantic", specifier = ">=2.13.5,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pyjsg", specifier = ">=0.12.3" }, @@ -2520,6 +2541,7 @@ requires-dist = [ { name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.6.0" }, { name = "watchdog", specifier = ">=0.9.0" }, ] +provides-extras = ["diff-stable"] [package.metadata.requires-dev] bigquery = [{ name = "sqlalchemy-bigquery", specifier = ">=1.9.0" }] @@ -2533,6 +2555,7 @@ dev = [ { name = "ipython-genutils" }, { name = "jsonpatch", specifier = ">=1.33" }, { name = "jupyter" }, + { name = "linkml-runtime", extras = ["diff-stable"], editable = "packages/linkml_runtime" }, { name = "mock", specifier = ">=5.1.0" }, { name = "myst-nb", marker = "python_full_version >= '3.10'", specifier = ">=1.4.0" }, { name = "nbconvert" }, @@ -2580,6 +2603,7 @@ shacl = [{ name = "pyshacl", specifier = ">=0.40.1" }] tests = [ { name = "black", specifier = ">=24.0.0" }, { name = "duckdb", specifier = ">=1.5.5" }, + { name = "linkml-runtime", extras = ["diff-stable"], editable = "packages/linkml_runtime" }, { name = "numpydantic", specifier = ">=1.10.0" }, { name = "pyshacl", specifier = ">=0.40.1" }, { name = "sqlalchemy-bigquery", specifier = ">=1.17.2" }, @@ -2609,6 +2633,7 @@ dependencies = [ { name = "click" }, { name = "curies" }, { name = "deprecated" }, + { name = "diffable-rdf" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "json-flattener" }, @@ -2628,6 +2653,9 @@ dev = [ { name = "coverage" }, { name = "requests-cache" }, ] +diff-stable = [ + { name = "diffable-rdf" }, +] [package.dev-dependencies] dev = [ @@ -2641,6 +2669,8 @@ requires-dist = [ { name = "coverage", marker = "extra == 'dev'" }, { name = "curies", specifier = ">=0.14.6" }, { name = "deprecated" }, + { name = "diffable-rdf", specifier = ">=0.4.0" }, + { name = "diffable-rdf", marker = "extra == 'diff-stable'", specifier = ">=0.4.0" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'", specifier = ">=0.7.2,<1.0.0" }, { name = "json-flattener", specifier = ">=0.1.9" }, @@ -2655,7 +2685,7 @@ requires-dist = [ { name = "requests" }, { name = "requests-cache", marker = "extra == 'dev'", specifier = ">=1.3.3" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "diff-stable"] [package.metadata.requires-dev] dev = [ @@ -2915,140 +2945,183 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.0" +version = "6.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153, upload-time = "2025-10-06T14:48:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993, upload-time = "2025-10-06T14:48:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607, upload-time = "2025-10-06T14:48:29.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847, upload-time = "2025-10-06T14:48:32.107Z" }, - { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616, upload-time = "2025-10-06T14:48:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333, upload-time = "2025-10-06T14:48:35.9Z" }, - { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239, upload-time = "2025-10-06T14:48:37.302Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618, upload-time = "2025-10-06T14:48:38.963Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655, upload-time = "2025-10-06T14:48:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245, upload-time = "2025-10-06T14:48:41.848Z" }, - { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523, upload-time = "2025-10-06T14:48:43.749Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129, upload-time = "2025-10-06T14:48:45.225Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999, upload-time = "2025-10-06T14:48:46.703Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711, upload-time = "2025-10-06T14:48:48.146Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504, upload-time = "2025-10-06T14:48:49.447Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422, upload-time = "2025-10-06T14:48:50.789Z" }, - { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050, upload-time = "2025-10-06T14:48:51.938Z" }, - { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153, upload-time = "2025-10-06T14:48:53.146Z" }, - { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, - { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, - { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, - { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, - { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, - { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, - { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, - { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, - { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, - { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, - { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, - { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, - { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, - { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, - { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, - { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, - { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, - { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, - { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, - { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, - { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, - { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, - { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, - { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, - { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, - { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, - { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, - { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, - { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, - { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, - { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, - { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, - { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, - { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, - { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, - { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, - { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, - { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, - { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, - { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, - { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, - { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, - { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, - { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, - { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, - { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, - { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, - { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, - { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, - { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, - { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/1c/fb2114207afc7f00ab9897ae5beeee27de5ec58ea8b9623e879f2c56f003/multidict-6.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8", size = 97307, upload-time = "2026-09-21T17:54:14.957Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/1ab8c7349adbb30c8cf41b88b0794740df8ea9f7de2e382da040a2f5bd8e/multidict-6.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19", size = 58830, upload-time = "2026-09-21T17:54:16.261Z" }, + { url = "https://files.pythonhosted.org/packages/e0/89/b845535014a9ad50537ca50204aaf6351c5368822a3acd9da854fb2a5a34/multidict-6.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa", size = 57645, upload-time = "2026-09-21T17:54:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4f/28ebb40a6f8e8aa7be78bcbe4bf9a1ac9518c40ef88accf2d4cd6ed7a7e0/multidict-6.9.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299", size = 301535, upload-time = "2026-09-21T17:54:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/33/51/0de5fc980df34d1ffed9ef68f60ce86c2d4baa3d422598a53f8730c56757/multidict-6.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96", size = 298796, upload-time = "2026-09-21T17:54:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/dd86067bdd2e961fdee8ec659fabc50f4e76403616b508714ec2686b0a49/multidict-6.9.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188", size = 282392, upload-time = "2026-09-21T17:54:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/2f/52/5464a2675579e3adb48b7cd4707a59da499660f0e22c024bc7281d2d9bdf/multidict-6.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a", size = 314538, upload-time = "2026-09-21T17:54:23.366Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/7f2b3fafe064181669157ce872a097b08a0546fb6fd2481e8c5604b47ec3/multidict-6.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e", size = 315675, upload-time = "2026-09-21T17:54:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/bd/53/f65ed68b96eea2b4f6fa4f9031bdfccd8f3097c429e3d371c545d1c40ca4/multidict-6.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab", size = 303685, upload-time = "2026-09-21T17:54:26.236Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5f/ade05795c6fb9adfccd61695a0f0509bc793583f589c4b1b87f27215a139/multidict-6.9.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88", size = 280306, upload-time = "2026-09-21T17:54:27.685Z" }, + { url = "https://files.pythonhosted.org/packages/60/b2/c996349a61e0f43c7c95877943a67476261bf906ff993670afbd33ff5bf4/multidict-6.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1", size = 291691, upload-time = "2026-09-21T17:54:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/8b7016d1084854729c80acfae71c5a51ea7e7c9411d66c3e2729772f5723/multidict-6.9.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c", size = 290357, upload-time = "2026-09-21T17:54:30.491Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/801653b6aa9343a7430cecdefa1bf71ff24432224c02f15cc15e47837652/multidict-6.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126", size = 305831, upload-time = "2026-09-21T17:54:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f3/9fc997861920bdbf70db82bdce5c802ec6477754c4bd78cff2898e3c63f9/multidict-6.9.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410", size = 308995, upload-time = "2026-09-21T17:54:33.571Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5f/278b5a2c99d6de27914b49158dd6f2a6481e53359142cc366dff04821a2f/multidict-6.9.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5", size = 279240, upload-time = "2026-09-21T17:54:35.025Z" }, + { url = "https://files.pythonhosted.org/packages/3d/db/0922594c601a691eb4365a83f4f92104ef87a9728d8066fa25c99349e754/multidict-6.9.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4", size = 305487, upload-time = "2026-09-21T17:54:36.555Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/07bca86d4a361a87128f3019540d7689800fdcad44e3b3ad08f1f035ed74/multidict-6.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c", size = 301997, upload-time = "2026-09-21T17:54:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/99591bf626613ea35779d2460949b45e59e589dc9058d6693c923eccd4e5/multidict-6.9.1-cp310-cp310-win32.whl", hash = "sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9", size = 51205, upload-time = "2026-09-21T17:54:39.152Z" }, + { url = "https://files.pythonhosted.org/packages/ae/18/10c008aac77297aa618631e8726ece31668f9dc9d9c848e4b5d2e68eb3d3/multidict-6.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0", size = 59049, upload-time = "2026-09-21T17:54:40.465Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/da73a58bef2613571902a824f1e11185207ba756215c99038f6d220bc167/multidict-6.9.1-cp310-cp310-win_arm64.whl", hash = "sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2", size = 54610, upload-time = "2026-09-21T17:54:41.605Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] @@ -3712,16 +3785,12 @@ wheels = [ [[package]] name = "prefixmaps" -version = "0.2.6" -source = { registry = "https://pypi.org/simple" } +version = "0.2.7.post2.dev0+7543515" +source = { git = "https://github.com/linkml/prefixmaps?rev=75435150a1b31760b9780af2b64a265943a9b263#75435150a1b31760b9780af2b64a265943a9b263" } dependencies = [ { name = "curies" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/cf/f588bcdfd2c841839b9d59ce219a46695da56aa2805faff937bbafb9ee2b/prefixmaps-0.2.6.tar.gz", hash = "sha256:7421e1244eea610217fa1ba96c9aebd64e8162a930dc0626207cd8bf62ecf4b9", size = 709899, upload-time = "2024-10-17T16:30:57.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/b2/2b2153173f2819e3d7d1949918612981bc6bd895b75ffa392d63d115f327/prefixmaps-0.2.6-py3-none-any.whl", hash = "sha256:f6cef28a7320fc6337cf411be212948ce570333a0ce958940ef684c7fb192a62", size = 754732, upload-time = "2024-10-17T16:30:55.731Z" }, -] [[package]] name = "prettytable" @@ -4956,23 +5025,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -4990,23 +5059,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals-py" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [