Skip to content
Open
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
5 changes: 5 additions & 0 deletions docs/maintainers/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ New tests in any directory should be written using pytest.
If you make a change that intentionally causes some output to not match the saved snapshot file(s), you should update the snapshots by running `pytest` with the `--generate-snapshots` flag. You should try to run only a single or small group of tests with this flag (as opposed to the entire test suite). An exception to this rule is when preparing a new minor version of linkml after the metamodel changes, changes to the metamodel can have many (inconsequential) changes to multiple snapshots.
The updated snapshot files should be checked in to Git alongside your other code changes.

Examples:

`uv run pytest tests/linkml/test_scripts/test_gen_owl.py --with-slow --generate-snapshots`
`uv run pytest tests/linkml/test_scripts/test_gen_shex.py --generate-snapshots --with-network`

Debugging tip: sometimes a snapshot-based test may fail on GitHub actions, but may appear to pass locally. This can happen if the test is marked as a slow test,
in which case you may need to use `--generate-snapshots` in combination with `--with-slow` (see below).

Expand Down
2 changes: 1 addition & 1 deletion examples/tutorial/tutorial01/personinfo.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"$id": "https://w3id.org/linkml/examples/personinfo",
"$schema": "https://json-schema.org/draft/2019-09/schema",
"additionalProperties": true,
"metamodel_version": "1.7.0",
"metamodel_version": "1.11.0",
"title": "personinfo",
"type": "object",
"version": null
Expand Down
2 changes: 1 addition & 1 deletion examples/tutorial/tutorial04/personinfo-semantic.shex
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# metamodel_version: 1.7.0
# metamodel_version: 1.11.0
BASE <https://w3id.org/linkml/examples/personinfo/>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
Expand Down
2 changes: 1 addition & 1 deletion examples/tutorial/tutorial05/personinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from linkml_runtime.utils.curienamespace import CurieNamespace
from linkml_runtime.linkml_model.types import Integer, String

metamodel_version = "1.7.0"
metamodel_version = "1.11.0"

# Namespaces
ORCID = CurieNamespace('ORCID', 'https://orcid.org/')
Expand Down
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
16 changes: 9 additions & 7 deletions packages/linkml/src/linkml/generators/excelgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,21 @@ def _create_workbook_and_worksheets(self, output_path: Path, classes: list[str])
workbook.remove(workbook.active)
sv = self.schemaview

# Compute enum lookup before next inner loop
enum_set = set(sv.all_enums(imports=self.mergeimports).keys())

for cls_name in classes:
workbook.create_sheet(cls_name)

# Add columns to the worksheet for the current class
slots = [s.name for s in sv.class_induced_slots(cls_name, self.mergeimports)]
self.add_columns_to_worksheet(workbook, cls_name, slots)
workbook.save(output_path)
# (call class_induced_slots, reuse for heading/enum validation)
induced_slots = list(sv.class_induced_slots(cls_name, self.mergeimports))
slot_names = [s.name for s in induced_slots]
self.add_columns_to_worksheet(workbook, cls_name, slot_names)

# Add enum validation for columns with enum types
enum_list = list(sv.all_enums(imports=self.mergeimports).keys())
for s in sv.class_induced_slots(cls_name, self.mergeimports):
if s.range in enum_list:
for s in induced_slots:
if s.range in enum_set:
pv_list = list(sv.get_enum(s.range).permissible_values.keys())

# Data Validation formula to be applied to the column and
Expand All @@ -81,7 +84,6 @@ def _create_workbook_and_worksheets(self, output_path: Path, classes: list[str])
"length > 255 characters. Dropdowns may not work properly "
f"in {output_path}"
)
workbook.save(output_path)

workbook.save(output_path)
if self.split_workbook_by_class:
Expand Down
86 changes: 83 additions & 3 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 @@ -239,14 +249,21 @@ def visit_class(self, cls: ClassDefinition) -> bool:
self.add_mappings(cls)

self._build_element_id(class_def, cls.class_uri)

# Build scoped context for slots whose range differs from the global definition.
# This handles attributes and slot_usage overrides that change a slot's range
# within a specific class (see: JSON-LD Scoped Contexts).
scoped_context = self._build_scoped_context(cls)
if scoped_context:
class_def["@context"] = scoped_context

if class_def:
self.slot_class_maps[cn] = class_def

# prefer explicit tree_root for frame @type
if getattr(cls, "tree_root", False):
self.frame_root = cls.name

# We don't bother to visit class slots - just all slots
return True

def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | None]:
Expand All @@ -263,6 +280,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 +289,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 +334,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 All @@ -339,6 +358,61 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None:
if slot.range in self.schema.classes and slot.inlined is not None:
self.frame_body[key] = {"@embed": "@always" if bool(slot.inlined) else "@never"}

def _resolve_slot_type(self, range_name: str) -> str | None:
"""Resolve a slot range to its JSON-LD @type value.

Returns the appropriate @type string for a given range name,
or None if the range maps to xsd:string (no @type needed).
"""
if range_name in self.schema.classes:
return "@id"
if range_name in self.schema.enums:
return None
if range_name in self.schema.types:
range_type = self.schema.types[range_name]
uri = self.namespaces.uri_for(range_type.uri)
if uri == XSD.string:
return None
if uri in URI_RANGES:
return "@id"
return range_type.uri
return None

def _build_scoped_context(self, cls: ClassDefinition) -> dict:
"""Build a scoped JSON-LD context for class-level slot range overrides.

When a class overrides a slot's range (via attributes or slot_usage),
the global context entry won't have the right @type. This method
detects those overrides and returns context entries only when the
resolved @type actually differs from the global definition.
"""
scoped: dict = {}

def _add_if_type_differs(slot_name: str, override_range: str) -> None:
"""Add a scoped entry if the override's @type differs from the global slot's @type."""
global_slot = self.schema.slots[slot_name]
global_type = self._resolve_slot_type(global_slot.range) if global_slot.range else None
override_type = self._resolve_slot_type(override_range)
if override_type == global_type:
return
entry: dict = {}
self._build_element_id(entry, global_slot.slot_uri)
if override_type is not None:
entry["@type"] = override_type
scoped[underscore(slot_name)] = entry

# Check attributes that shadow global slots
for attr_name, attr in cls.attributes.items():
if attr.range and attr_name in self.schema.slots:
_add_if_type_differs(attr_name, attr.range)

# Check slot_usage overrides
for usage_name, usage in cls.slot_usage.items():
if usage.range and usage_name in self.schema.slots:
_add_if_type_differs(usage_name, usage.range)

return scoped

def _build_element_id(self, definition: Any, uri: str) -> None:
"""
Defines the elements @id attribute according to the default namespace prefix of the schema.
Expand Down Expand Up @@ -438,6 +512,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
29 changes: 29 additions & 0 deletions packages/linkml/src/linkml/generators/jsonschemagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,30 @@ 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

class_subschema = self.after_generate_class(
ClassResult.model_construct(schema_=class_subschema, source=cls), self.schemaview
).schema_
Expand Down Expand Up @@ -415,6 +439,11 @@ def get_subschema_for_anonymous_class(
subschema = JsonSchema()
for slot in cls.slot_conditions.values():
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
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
value_disallowed = False
if slot.value_presence:
Expand Down
Loading
Loading