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
38 changes: 29 additions & 9 deletions packages/linkml_runtime/src/linkml_runtime/utils/schemaview.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import TYPE_CHECKING, Any, TypeVar

from deprecated.classic import deprecated
from jsonasobj2 import items

from linkml_runtime.exceptions import OrderingError
from linkml_runtime.linkml_model.meta import (
Expand Down Expand Up @@ -1788,17 +1789,33 @@ def annotation_dict(
return {k: v.value for k, v in e.annotations.items()}

@lru_cache(None)
def _pattern_resolver(self, schema_id: str) -> PatternResolver:
def _pattern_resolver(self, schema_id: str, with_root_settings: bool = True) -> PatternResolver:
"""Return a pattern resolver configured with one schema's settings."""
schemas_by_id = {str(schema.id): schema for schema in self.all_schema()}
schema = schemas_by_id.get(schema_id, self.schema)
return PatternResolver(SchemaView(schema))
return self._schema_pattern_resolver(schemas_by_id.get(schema_id, self.schema), with_root_settings)

def _schema_pattern_resolver(self, schema: SchemaDefinition, with_root_settings: bool) -> PatternResolver:
"""Return a resolver for a schema's settings, optionally falling back to the root schema's settings.

A setting declared in the defining schema wins. With ``with_root_settings``, a setting it does
not declare is taken from the schema this view was loaded from, so a module that relies on its
importing schema's settings, as linkml-runtime 1.11 allowed, still resolves. The fallback is
only for interpolation: escaping a non-interpolated pattern uses the defining schema's own
settings, so root settings never change a pattern that does not ask for them.
See https://github.com/linkml/linkml/issues/4135.
"""
resolver = PatternResolver(SchemaView(schema))
if with_root_settings and schema is not self.schema and self.schema.settings:
for key, setting in items(self.schema.settings):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the use of the jsonasobj2.items function here. Elsewhere in this module, a normal dict.items method call is used in similar situations. Is there a reason to diverge here?

resolver.format_spec.setdefault(key, setting.setting_value)
return resolver

def resolve_pattern(self, definition: SlotDefinition | TypeDefinition) -> str | None:
"""Return the effective regular-expression pattern for a slot or type definition.

Structured patterns are resolved using settings from the schema that defined the
supplied definition. The definition and underlying schemas are not modified.
supplied definition. A setting that schema does not declare is taken from the root
schema of this view. The definition and underlying schemas are not modified.

:param definition: slot or type definition whose pattern should be resolved
:return: asserted or materialized regular-expression pattern, if present
Expand All @@ -1807,7 +1824,9 @@ def resolve_pattern(self, definition: SlotDefinition | TypeDefinition) -> str |
if structured_pattern is None:
return definition.pattern

resolver = self._pattern_resolver(str(definition.from_schema or self.schema.id))
resolver = self._pattern_resolver(
str(definition.from_schema or self.schema.id), bool(structured_pattern.interpolated)

@pkalita-lbl pkalita-lbl Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unclear as to why structured_pattern.interpolated is being passed as the with_root_settings argument here. My understanding of the interpolated metamodel slot is that it controls whether values in the structured pattern's syntax that look like {this} get replaced by values from settings during materialization (see usage on line 1831). Why that would also control whether the root settings are consulted. What am I missing?

)
pattern = structured_pattern.syntax
if structured_pattern.interpolated:
pattern = resolver.resolve(pattern)
Expand Down Expand Up @@ -2538,7 +2557,7 @@ def materialize_patterns(self, imports: bool = True) -> None:
def _materialize_patterns(self, imports: bool = True) -> None:
"""Implement in-place pattern materialization for compatibility callers."""
schemas_by_id = {str(schema.id): schema for schema in self.all_schema(imports=imports)}
resolvers: dict[str, PatternResolver] = {}
resolvers: dict[tuple[str, bool], PatternResolver] = {}
modified = False

def resolver_for_definition(
Expand All @@ -2547,10 +2566,11 @@ def resolver_for_definition(
) -> PatternResolver:
source = owning_class if owning_class is not None else element_def
schema_id = str(source.from_schema) if source.from_schema is not None else str(self.schema.id)
if schema_id not in resolvers:
key = (schema_id, bool(element_def.structured_pattern.interpolated))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto previous comment about interpolated usage here.

if key not in resolvers:
schema = schemas_by_id.get(schema_id, self.schema)
resolvers[schema_id] = PatternResolver(SchemaView(schema))
return resolvers[schema_id]
resolvers[key] = self._schema_pattern_resolver(schema, key[1])
return resolvers[key]

def materialize_pattern_into_definition(
element_def: SlotDefinition | TypeDefinition,
Expand Down
47 changes: 47 additions & 0 deletions tests/linkml/test_generators/test_jsonschemagen.py
Original file line number Diff line number Diff line change
Expand Up @@ -1867,3 +1867,50 @@ def test_type_without_base_defaults_to_string(tmp_path):
generated = json.loads(JsonSchemaGenerator(str(schema_file)).serialize())

assert generated["$defs"]["MyClass"]["properties"]["value"]["type"] == ["string", "null"]


def test_imported_structured_pattern_uses_root_settings(tmp_path: Path) -> None:
"""An imported module without settings gets the root schema's settings (issue 4135)."""
(tmp_path / "ids.yaml").write_text(
"""
id: https://example.org/ids
name: ids
imports:
- linkml:types
default_range: string
classes:
Sample:
attributes:
id:
identifier: true
structured_pattern:
syntax: "^{prefix}:smp-[0-9]+$"
interpolated: true
"""
)
root = tmp_path / "root.yaml"
root.write_text(
"""
id: https://example.org/root
name: root
imports:
- linkml:types
- ids
settings:
prefix: "ex"
classes:
Container:
tree_root: true
attributes:
samples:
range: Sample
multivalued: true
inlined_as_list: true
"""
)
schema = json.loads(JsonSchemaGenerator(str(root)).serialize())

assert schema["$defs"]["Sample"]["properties"]["id"]["pattern"] == "^(?:^ex:smp-[0-9]+$)$"
jsonschema.validate({"samples": [{"id": "ex:smp-1"}]}, schema)
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate({"samples": [{"id": "{prefix}:smp-1"}]}, schema)
191 changes: 191 additions & 0 deletions tests/linkml_runtime/test_utils/test_schemaview.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import re
from contextlib import nullcontext
from copy import deepcopy
from pathlib import Path
Expand Down Expand Up @@ -3408,6 +3409,196 @@ def test_materialize_patterns_uses_defining_schema_settings(tmp_path: Path) -> N
assert view.get_type("ImportedType").pattern == r"^(?:[a-z]+)$"


def _write_import_pair(tmp_path: Path, imported_settings: str, root_settings: str) -> Path:
"""Write a root schema that imports a module holding a slot, slot_usage, attribute and type pattern."""
(tmp_path / "imported.yaml").write_text(
f"""
id: https://example.org/imported
name: imported
{imported_settings}
slots:
imported_slot:
structured_pattern:
syntax: "{{word}}-{{num}}"
interpolated: true
classes:
ImportedClass:
slots:
- imported_slot
slot_usage:
imported_slot:
structured_pattern:
syntax: "{{word}}{{word}}-{{num}}"
interpolated: true
attributes:
imported_attribute:
structured_pattern:
syntax: "{{num}}"
interpolated: true
types:
ImportedType:
base: str
structured_pattern:
syntax: "{{num}}"
interpolated: true
"""
)
root_schema = tmp_path / "root.yaml"
root_schema.write_text(
f"""
id: https://example.org/root
name: root
imports:
- imported
{root_settings}
"""
)
return root_schema


ROOT_SETTINGS = 'settings:\n word: "[a-z]+"\n num: "[0-9]+"'


def test_induced_patterns_fall_back_to_root_settings(tmp_path: Path) -> None:
"""A module with no settings of its own uses the root schema's settings (issue 4135)."""
view = SchemaView(str(_write_import_pair(tmp_path, "", ROOT_SETTINGS)))

assert view.induced_slot("imported_slot").pattern == r"^(?:[a-z]+-[0-9]+)$"
assert view.induced_slot("imported_slot", "ImportedClass").pattern == r"^(?:[a-z]+[a-z]+-[0-9]+)$"
assert view.induced_slot("imported_attribute", "ImportedClass").pattern == r"^(?:[0-9]+)$"
assert view.induced_type("ImportedType").pattern == r"^(?:[0-9]+)$"


def test_defining_schema_setting_wins_over_root_setting(tmp_path: Path) -> None:
"""Only the keys the defining schema lacks come from the root schema."""
imported_settings = 'settings:\n word: "[A-Z]+"'
view = SchemaView(str(_write_import_pair(tmp_path, imported_settings, ROOT_SETTINGS)))

assert view.induced_slot("imported_slot").pattern == r"^(?:[A-Z]+-[0-9]+)$"
assert view.induced_slot("imported_slot", "ImportedClass").pattern == r"^(?:[A-Z]+[A-Z]+-[0-9]+)$"


def test_resolve_pattern_falls_back_to_root_settings(tmp_path: Path) -> None:
view = SchemaView(str(_write_import_pair(tmp_path, "", ROOT_SETTINGS)))

assert view.resolve_pattern(view.get_slot("imported_slot")) == r"^(?:[a-z]+-[0-9]+)$"
assert view.resolve_pattern(view.get_type("ImportedType")) == r"^(?:[0-9]+)$"


def test_materialize_patterns_falls_back_to_root_settings(tmp_path: Path) -> None:
"""The deprecated in-place path resolves the same way as induced_slot."""
view = SchemaView(str(_write_import_pair(tmp_path, "", ROOT_SETTINGS)))

view.materialize_patterns()

assert view.get_slot("imported_slot").pattern == r"^(?:[a-z]+-[0-9]+)$"
imported_class = view.get_class("ImportedClass")
assert imported_class.slot_usage["imported_slot"].pattern == r"^(?:[a-z]+[a-z]+-[0-9]+)$"
assert imported_class.attributes["imported_attribute"].pattern == r"^(?:[0-9]+)$"
assert view.get_type("ImportedType").pattern == r"^(?:[0-9]+)$"


def test_fallback_does_not_modify_the_imported_schema(tmp_path: Path) -> None:
view = SchemaView(str(_write_import_pair(tmp_path, "", ROOT_SETTINGS)))

view.induced_slot("imported_slot")
imported = next(schema for schema in view.all_schema() if schema.name == "imported")

assert not imported.settings


def test_placeholder_without_any_setting_stays_literal(tmp_path: Path) -> None:
"""Neither schema declares num, so it is left as text, as before."""
root_settings = 'settings:\n word: "[a-z]+"'
view = SchemaView(str(_write_import_pair(tmp_path, "", root_settings)))

assert view.induced_slot("imported_slot").pattern == r"^(?:[a-z]+-{num})$"


def test_fallback_uses_the_root_of_the_view(tmp_path: Path) -> None:
"""Loaded on its own, the imported module has no root settings to fall back to."""
_write_import_pair(tmp_path, "", ROOT_SETTINGS)
view = SchemaView(str(tmp_path / "imported.yaml"))

assert view.induced_slot("imported_slot").pattern == r"^(?:{word}-{num})$"


def test_root_settings_do_not_escape_uninterpolated_patterns(tmp_path: Path) -> None:
"""A root setting must not change a non-interpolated pattern in a module without that setting."""
(tmp_path / "imported.yaml").write_text(
r"""
id: https://example.org/imported
name: imported
slots:
spaced:
structured_pattern:
syntax: '^a\N{SPACE}b$'
types:
Spaced:
base: str
structured_pattern:
syntax: '^a\N{SPACE}b$'
"""
)
(tmp_path / "root.yaml").write_text(
"""
id: https://example.org/root
name: root
imports:
- imported
settings:
SPACE: "x"
"""
)
view = SchemaView(str(tmp_path / "root.yaml"))

expected = r"^(?:^a\N{SPACE}b$)$"
assert view.induced_slot("spaced").pattern == expected
assert view.induced_type("Spaced").pattern == expected
assert re.fullmatch(expected, "a b")
view.materialize_patterns()
assert view.get_slot("spaced").pattern == expected
assert view.get_type("Spaced").pattern == expected


def test_fallback_skips_intermediate_imports(tmp_path: Path) -> None:
"""In root -> middle -> leaf, leaf falls back to root's settings, not middle's."""
(tmp_path / "leaf.yaml").write_text(
"""
id: https://example.org/leaf
name: leaf
slots:
leaf_slot:
structured_pattern:
syntax: "{word}"
interpolated: true
"""
)
(tmp_path / "middle.yaml").write_text(
"""
id: https://example.org/middle
name: middle
imports:
- leaf
settings:
word: "middle"
"""
)
(tmp_path / "root.yaml").write_text(
"""
id: https://example.org/root
name: root
imports:
- middle
settings:
word: "root"
"""
)
view = SchemaView(str(tmp_path / "root.yaml"))

assert view.induced_slot("leaf_slot").pattern == r"^(?:root)$"


def test_materialize_patterns_slot_usage(sv_structured_patterns: SchemaView) -> None:
"""Test pattern materialization with slot_usage."""
sv_structured_patterns.materialize_patterns()
Expand Down
Loading