Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/linkml/src/linkml/converter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import pathlib
import sys
from typing import TYPE_CHECKING

import click
import yaml
Expand All @@ -27,6 +28,9 @@
from linkml_runtime.utils.inference_utils import infer_all_slot_values
from linkml_runtime.utils.schemaview import SchemaView

if TYPE_CHECKING:
from linkml_runtime.utils.yamlutils import YAMLRoot

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -197,7 +201,7 @@ def cli(
target_class = infer_root_class(sv)
if target_class is None:
raise Exception("target class not specified and could not be inferred")
py_target_class = python_module.__dict__[target_class]
py_target_class: YAMLRoot = python_module.__dict__[target_class]
input_format = _get_format(input, input_format)
loader = get_loader(input_format)

Expand Down Expand Up @@ -237,9 +241,7 @@ def cli(
raise Exception("--schema must be passed in order to validate. Suppress with --no-validate")
obj_dict = json_dumper.to_dict(obj)
report = run_validation(obj_dict, schema, target_class)
if report.results:
errors = "\n".join(r.message for r in report.results)
raise Exception(f"Validation failed:\n{errors}")
report.raise_for_results()

output_format = _get_format(output, output_format, default="json")
if output_format == "json-ld":
Expand Down
33 changes: 33 additions & 0 deletions packages/linkml/src/linkml/generators/common/subproperty.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
CURIE_TYPES: frozenset[str] = frozenset({"uriorcurie", "curie"})
URI_TYPES: frozenset[str] = frozenset({"uri"})

# Types whose XSD mapping is xsd:anyURI (not xsd:string).
# ``curie`` maps to xsd:string and is deliberately excluded.
_ANYURI_TYPES: frozenset[str] = frozenset({"uri", "uriorcurie"})


def is_uri_range(sv: SchemaView, range_type: str | None) -> bool:
"""
Expand Down Expand Up @@ -63,6 +67,35 @@ 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.
Expand Down
22 changes: 20 additions & 2 deletions packages/linkml/src/linkml/generators/jsonldcontextgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@

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",
Expand Down Expand Up @@ -72,6 +76,12 @@ class ContextGenerator(Generator):
_local_slots: set | None = field(default=None, repr=False)
_external_classes: set | None = field(default=None, repr=False)
_external_slots: set | None = field(default=None, repr=False)
xsd_anyuri_as_iri: bool = False
"""Map xsd:anyURI-typed ranges (uri, uriorcurie) to ``@type: @id`` instead of ``@type: xsd:anyURI``.

This aligns the JSON-LD context with the SHACL generator, which emits
``sh:nodeKind sh:IRI`` for the same types.
"""

# Framing (opt-in via CLI flag)
emit_frame: bool = False
Expand Down Expand Up @@ -263,6 +273,7 @@ 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
Expand All @@ -271,7 +282,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 range_uri in uri_ranges:
coercions.add("@id")
else:
coercions.add(range_type.uri)
Expand Down Expand Up @@ -316,9 +327,10 @@ 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.namespaces.uri_for(range_type.uri) in uri_ranges:
slot_def["@type"] = "@id"
else:
slot_def["@type"] = range_type.uri
Expand Down Expand Up @@ -438,6 +450,12 @@ def serialize(
help="Exclude elements from URL-based external vocabulary imports while keeping local file imports. "
"Useful when extending ontologies (e.g. W3C VC v2) whose terms are @protected in their own JSON-LD context.",
)
@click.option(
"--xsd-anyuri-as-iri/--no-xsd-anyuri-as-iri",
default=False,
show_default=True,
help="Map xsd:anyURI-typed ranges (uri, uriorcurie) to @type: @id instead of @type: xsd:anyURI.",
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile, emit_frame, embed_context_in_frame, output, **args):
"""Generate jsonld @context definition from LinkML model"""
Expand Down
49 changes: 43 additions & 6 deletions packages/linkml/src/linkml/generators/owlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from linkml import METAMODEL_NAMESPACE_NAME
from linkml._version import __version__
from linkml.generators.common.subproperty import is_xsd_anyuri_range
from linkml.utils.deprecation import deprecation_warning
from linkml.utils.generator import Generator, shared_arguments
from linkml_runtime import SchemaView
Expand Down Expand Up @@ -215,6 +216,24 @@ def _present(values: Iterable[_T | None]) -> list[_T]:

return [value for value in values if value is not None]

xsd_anyuri_as_iri: bool = False
"""Treat ``range: uri`` / ``range: uriorcurie`` slots as ``owl:ObjectProperty``
instead of ``owl:DatatypeProperty`` with ``rdfs:range xsd:anyURI``.

This aligns the OWL output with the SHACL generator (which emits
``sh:nodeKind sh:IRI``) and the JSON-LD context generator (which emits
``@type: @id`` when its own ``--xsd-anyuri-as-iri`` flag is set).

Without this flag, ``range: uri`` produces a semantic inconsistency:
OWL says the value is a literal (``DatatypeProperty``), while SHACL and
JSON-LD say it is an IRI node. Enabling the flag makes all three
generators consistent.

When enabled, URI-range slots:
- become ``owl:ObjectProperty`` (not ``owl:DatatypeProperty``)
- have no ``rdfs:range`` restriction (any IRI is valid)
"""

def as_graph(self) -> Graph:
"""
Generate an rdflib Graph from the LinkML schema.
Expand Down Expand Up @@ -785,10 +804,14 @@ def _get_slot_nodes(
this_owl_types: set[OWL_TYPE] = set()
if slot_range:
if slot_range in sv.all_types(imports=True):
self.slot_is_literal_map[main_slot.name].add(True)
this_owl_types.add(RDFS.Literal)
typ = sv.get_type(slot_range)
owl_exprs.append(self._type_uri(typ.name))
if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(sv, slot_range):
self.slot_is_literal_map[main_slot.name].add(False)
this_owl_types.add(OWL.Thing)
else:
self.slot_is_literal_map[main_slot.name].add(True)
this_owl_types.add(RDFS.Literal)
typ = sv.get_type(slot_range)
owl_exprs.append(self._type_uri(typ.name))
elif slot_range in sv.all_enums(imports=True):
# TODO: enums fill this in
owl_exprs.append(self._enum_uri(EnumDefinitionName(slot_range)))
Expand Down Expand Up @@ -1388,8 +1411,9 @@ def _boolean_expression(
def _range_is_datatype(self, slot: SlotDefinition) -> bool:
if self.type_objects:
return False
else:
return slot.range in self.schema.types
if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(self.schemaview, slot.range):
return False
return slot.range in self.schema.types

def _range_uri(self, slot: SlotDefinition) -> URIRef:
if slot.range in self.schema.types:
Expand Down Expand Up @@ -1508,6 +1532,8 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef:
elif slot_range in sv.all_enums():
return OWL.ObjectProperty
elif slot_range in sv.all_types():
if self.xsd_anyuri_as_iri and is_xsd_anyuri_range(sv, slot_range):
return OWL.ObjectProperty
return OWL.DatatypeProperty
else:
raise Exception(f"Unknown range: {slot.range}")
Expand Down Expand Up @@ -1630,6 +1656,17 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef:
"By default such axioms are emitted for every abstract class that has direct is_a children."
),
)
@click.option(
"--xsd-anyuri-as-iri/--no-xsd-anyuri-as-iri",
default=False,
show_default=True,
help=(
"Treat range: uri / range: uriorcurie slots as owl:ObjectProperty (IRI node) "
"instead of owl:DatatypeProperty with rdfs:range xsd:anyURI (literal). "
"Aligns OWL output with the SHACL generator (sh:nodeKind sh:IRI) and "
"the JSON-LD context generator (--xsd-anyuri-as-iri → @type: @id)."
),
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile: str, metadata_profile: str, **kwargs: Any) -> None:
"""Generate an OWL representation of a LinkML model
Expand Down
20 changes: 10 additions & 10 deletions packages/linkml/src/linkml/generators/pydanticgen/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from linkml.generators.pydanticgen.build import RangeResult
from linkml.generators.pydanticgen.template import ConditionalImport, Import, Imports, ObjectImport
from linkml.utils.exceptions import ValidationError
from linkml.utils.exceptions import SchemaValidationError


class ArrayRepresentation(Enum):
Expand Down Expand Up @@ -77,7 +77,7 @@ def validate(cls, array: ArrayExpression):
Validate an array expression.

Raises:
:class:`.ValidationError` if invalid
:class:`.SchemaValidationError` if invalid
"""
cls.array_exact_dimensions(array)
cls.array_consistent_n_dimensions(array)
Expand All @@ -94,7 +94,7 @@ def validate_dimension(cls, dimension: DimensionExpression):
Validate a single array dimension

Raises:
:class:`.ValidationError` if invalid
:class:`.SchemaValidationError` if invalid
"""
cls.dimension_exact_cardinality(dimension)
cls.dimension_ordinal(dimension)
Expand All @@ -105,7 +105,7 @@ def array_exact_dimensions(array: ArrayExpression):
if array.exact_number_dimensions is not None and (
array.minimum_number_dimensions is not None or array.maximum_number_dimensions is not None
):
raise ValidationError(
raise SchemaValidationError(
f"Can only specify EITHER exact_number_dimensions OR minimum/maximum dimensions, got: {array}"
)

Expand All @@ -121,7 +121,7 @@ def array_consistent_n_dimensions(array: ArrayExpression):
for field_name in _BOUNDED_ARRAY_FIELDS:
field = getattr(array, field_name, None)
if field and field < len(array.dimensions):
raise ValidationError(
raise SchemaValidationError(
"if exact/minimum/maximum_number_dimensions is provided, "
"it must be greater than the parameterized dimensions. "
f"got\n- {field_name}: {field}\n- dimensions: {array.dimensions}"
Expand All @@ -134,7 +134,7 @@ def array_dimensions_ordinal(array: ArrayExpression):
"""
if array.minimum_number_dimensions is not None and array.maximum_number_dimensions:
if array.minimum_number_dimensions > array.maximum_number_dimensions:
raise ValidationError(
raise SchemaValidationError(
"minimum_number_dimensions must be lesser than maximum_number_dimensions when both are set. "
f"got minimum: {array.minimum_number_dimensions}, maximum: {array.maximum_number_dimensions}"
)
Expand All @@ -148,7 +148,7 @@ def array_explicitly_unbounded(array: ArrayExpression):
dimensions to avoid ambiguity.
"""
if array.minimum_number_dimensions is not None and array.maximum_number_dimensions is None and array.dimensions:
raise ValidationError(
raise SchemaValidationError(
"Cannot specify a minimum_number_dimensions while maximum is None while using labeled dimensions - "
"either use exact_number_dimensions > len(dimensions) for extra parameterized dimensions or set "
"maximum_number_dimensions explicitly to False for unbounded dimensions"
Expand All @@ -160,7 +160,7 @@ def dimension_exact_cardinality(dimension: DimensionExpression):
if dimension.exact_cardinality is not None and (
dimension.minimum_cardinality is not None or dimension.maximum_cardinality is not None
):
raise ValidationError(
raise SchemaValidationError(
f"Can only specify EITHER exact_cardinality OR minimum/maximum cardinality, got: {dimension}"
)

Expand All @@ -169,7 +169,7 @@ def dimension_ordinal(dimension: DimensionExpression):
"""minimum_cardinality must be less than maximum_cardinality when both are set"""
if dimension.minimum_cardinality is not None and dimension.maximum_cardinality is not None:
if dimension.minimum_cardinality > dimension.maximum_cardinality:
raise ValidationError(
raise SchemaValidationError(
"minimum_cardinality must be lesser than maximum_cardinality when both are set. "
f"got minimum: {dimension.minimum_cardinality}, maximum: {dimension.maximum_cardinality}"
)
Expand Down Expand Up @@ -232,7 +232,7 @@ def validate(self):
rather than when an array is generated

Raises:
:class:`.ValidationError` if the schema is invalid
:class:`.SchemaValidationError` if the schema is invalid
"""
ArrayValidator.validate(self.array)

Expand Down
Loading
Loading