Skip to content

Commit 8dc3ece

Browse files
committed
feat(generators): add --normalize-prefixes flag for well-known prefix names
Add an opt-in --normalize-prefixes flag to OWL, SHACL, and JSON-LD Context generators that normalises non-standard prefix aliases to well-known names from a static prefix map (derived from rdflib 7.x defaults, cross-checked against prefix.cc consensus). Key design decisions: - Static frozen map (MappingProxyType) instead of runtime Graph().namespaces() lookup eliminates rdflib version dependency - Both http://schema.org/ and https://schema.org/ map to 'schema' - Shared normalize_graph_prefixes() helper used by OWL and SHACL - Two-phase graph normalisation: Phase 1 normalises schema-declared prefixes, Phase 2 cleans up runtime-injected bindings - Collision detection: skip with warning when standard prefix name is already user-declared for a different namespace - Phase 2 guard prevents overwriting HTTPS bindings with HTTP variants The flag defaults to off, preserving existing behaviour. Tests cover OWL, SHACL, and context generators with sdo->schema, dce->dc, http/https edge case, custom prefix preservation, flag-off backward compatibility, cross-generator consistency, prefix collision detection, schema1 regression prevention, Phase 2 HTTPS guard, empty schema edge case, and static map integrity. Signed-off-by: jdsika <carlo.van-driesten@bmw.de> Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
1 parent 1c5f68e commit 8dc3ece

9 files changed

Lines changed: 928 additions & 18 deletions

File tree

packages/linkml/pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/
4949
"openpyxl",
5050
"parse",
5151
"prefixcommons >= 0.1.7",
52-
"prefixmaps >= 0.2.2",
52+
"prefixmaps @ git+https://github.com/linkml/prefixmaps@75435150a1b31760b9780af2b64a265943a9b263", # >= 0.2.8 (unreleased: includes linkml/prefixmaps#81 W3C/OGC prefixes)
5353
"pydantic >= 2.0.0, < 3.0.0",
5454
"pyjsg >= 0.11.6",
5555
"pyshex >= 0.7.20",
@@ -196,6 +196,9 @@ vcs = "git"
196196
style = "pep440"
197197
fallback-version = "0.0.0"
198198

199+
[tool.hatch.metadata]
200+
allow-direct-references = true
201+
199202
[tool.hatch.version]
200203
source = "uv-dynamic-versioning"
201204

packages/linkml/src/linkml/generators/jsonldcontextgen.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from linkml._version import __version__
1717
from linkml.utils.deprecation import deprecated_fields
18-
from linkml.utils.generator import Generator, shared_arguments
18+
from linkml.utils.generator import Generator, shared_arguments, well_known_prefix_map
1919
from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition
2020
from linkml_runtime.linkml_model.types import SHEX
2121
from linkml_runtime.utils.formatutils import camelcase, underscore
@@ -90,6 +90,9 @@ class ContextGenerator(Generator):
9090
frame_root: str | None = None
9191

9292
def __post_init__(self) -> None:
93+
# Must be set before super().__post_init__() because the parent triggers
94+
# the visitor pattern (visit_schema), which accesses _prefix_remap.
95+
self._prefix_remap: dict[str, str] = {}
9396
super().__post_init__()
9497
if self.namespaces is None:
9598
raise TypeError("Schema text must be supplied to context generator. Preparsed schema will not work")
@@ -127,22 +130,92 @@ def _collect_external_elements(sv: SchemaView) -> tuple[set[str], set[str]]:
127130
external_slots.update(schema_def.slots.keys())
128131
return external_classes, external_slots
129132

133+
def add_prefix(self, ncname: str) -> None:
134+
"""Add a prefix, applying well-known prefix normalisation when enabled."""
135+
super().add_prefix(self._prefix_remap.get(ncname, ncname))
136+
130137
def visit_schema(self, base: str | Namespace | None = None, output: str | None = None, **_):
131-
# Add any explicitly declared prefixes
138+
# Add any explicitly declared prefixes.
139+
# Direct .add() is safe here: the normalisation block below explicitly
140+
# rewrites emit_prefixes entries for any renamed prefixes (Cases 1-3).
132141
for prefix in self.schema.prefixes.values():
133142
self.emit_prefixes.add(prefix.prefix_prefix)
134143

135144
# Add any prefixes explicitly declared
136145
for pfx in self.schema.emit_prefixes:
137146
self.add_prefix(pfx)
138147

148+
# Normalise well-known prefix names when --normalize-prefixes is set.
149+
# If the schema declares a non-standard alias for a namespace that has
150+
# a well-known standard name (e.g. ``sdo`` for
151+
# ``https://schema.org/``), replace the alias with the standard name
152+
# so that generated JSON-LD contexts use the conventional prefix.
153+
#
154+
# Three cases are handled:
155+
# 1. Standard prefix is not yet bound → just rebind from old to new.
156+
# 2. Standard prefix is bound to a *different* URI:
157+
# a. User-declared (in schema.prefixes) → collision, skip with warning.
158+
# b. Runtime default (e.g. linkml-runtime's ``schema: http://…``)
159+
# → remove stale binding, then rebind.
160+
# 3. Standard prefix is already bound to the *same* URI (duplicate)
161+
# → just drop the non-standard alias.
162+
#
163+
# A remap dict is stored for ``_build_element_id`` because
164+
# ``prefix_suffix()`` splits CURIEs on ``:`` without looking up the
165+
# namespace dict.
166+
self._prefix_remap.clear()
167+
if self.normalize_prefixes:
168+
wk = well_known_prefix_map()
169+
for old_pfx in list(self.namespaces):
170+
url = str(self.namespaces[old_pfx])
171+
std_pfx = wk.get(url)
172+
if not std_pfx or std_pfx == old_pfx:
173+
continue
174+
if std_pfx in self.namespaces:
175+
if str(self.namespaces[std_pfx]) != url:
176+
# Case 2: std_pfx is bound to a different URI.
177+
# If the user explicitly declared std_pfx in the schema,
178+
# it is intentional — skip to avoid data loss.
179+
if std_pfx in self.schema.prefixes:
180+
self.logger.warning(
181+
"Prefix collision: cannot rename '%s' to '%s' because '%s' is "
182+
"already declared for <%s>; skipping normalisation for <%s>",
183+
old_pfx,
184+
std_pfx,
185+
std_pfx,
186+
str(self.namespaces[std_pfx]),
187+
url,
188+
)
189+
continue
190+
# Not user-declared (e.g. linkml-runtime default) — safe to remove
191+
self.emit_prefixes.discard(std_pfx)
192+
del self.namespaces[std_pfx]
193+
else:
194+
# Case 3: standard prefix already bound to same URI
195+
# — just drop the non-standard alias
196+
del self.namespaces[old_pfx]
197+
if old_pfx in self.emit_prefixes:
198+
self.emit_prefixes.discard(old_pfx)
199+
self.emit_prefixes.add(std_pfx)
200+
self._prefix_remap[old_pfx] = std_pfx
201+
continue
202+
# Case 1 (or Case 2 after stale removal): bind standard name
203+
self.namespaces[std_pfx] = self.namespaces[old_pfx]
204+
del self.namespaces[old_pfx]
205+
if old_pfx in self.emit_prefixes:
206+
self.emit_prefixes.discard(old_pfx)
207+
self.emit_prefixes.add(std_pfx)
208+
self._prefix_remap[old_pfx] = std_pfx
209+
139210
# Add the default prefix
140211
if self.schema.default_prefix:
141212
dflt = self.namespaces.prefix_for(self.schema.default_prefix)
142213
if dflt:
143214
self.default_ns = dflt
144215
if self.default_ns:
145216
default_uri = self.namespaces[self.default_ns]
217+
# Direct .add() is safe: default_ns is already resolved from
218+
# the (possibly normalised) namespace bindings above.
146219
self.emit_prefixes.add(self.default_ns)
147220
else:
148221
default_uri = self.schema.default_prefix
@@ -486,6 +559,11 @@ def _build_element_id(self, definition: Any, uri: str) -> None:
486559
@return: None
487560
"""
488561
uri_prefix, uri_suffix = self.namespaces.prefix_suffix(uri)
562+
# Apply well-known prefix normalisation (e.g. sdo → schema).
563+
# prefix_suffix() splits CURIEs on ':' without checking the
564+
# namespace dict, so it may return a stale alias.
565+
if uri_prefix and uri_prefix in self._prefix_remap:
566+
uri_prefix = self._prefix_remap[uri_prefix]
489567
is_default_namespace = uri_prefix == self.context_body["@vocab"] or uri_prefix == self.namespaces.prefix_for(
490568
self.context_body["@vocab"]
491569
)

packages/linkml/src/linkml/generators/jsonldgen.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ def end_schema(self, context: str | Sequence[str] | None = None, context_kwargs:
179179
# TODO: The _visit function above alters the schema in situ
180180
# force some context_kwargs
181181
context_kwargs["metadata"] = False
182+
# Forward prefix normalisation into the inline @context.
183+
context_kwargs.setdefault("normalize_prefixes", self.normalize_prefixes)
182184
add_prefixes = ContextGenerator(self.original_schema, **context_kwargs).serialize()
183185
add_prefixes_json = loads(add_prefixes)
184186
metamodel_ctx = self.metamodel_context or METAMODEL_CONTEXT_URI

packages/linkml/src/linkml/generators/owlgen.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from linkml._version import __version__
2222
from linkml.generators.common.subproperty import is_xsd_anyuri_range
2323
from linkml.utils.deprecation import deprecation_warning
24-
from linkml.utils.generator import Generator, shared_arguments
24+
from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments
2525
from linkml_runtime import SchemaView
2626
from linkml_runtime.linkml_model.meta import (
2727
AnonymousClassExpression,
@@ -264,6 +264,10 @@ def as_graph(self) -> Graph:
264264
self.graph.bind(prefix, self.metamodel.namespaces[prefix])
265265
for pfx in schema.prefixes.values():
266266
self.graph.namespace_manager.bind(pfx.prefix_prefix, URIRef(pfx.prefix_reference))
267+
if self.normalize_prefixes:
268+
normalize_graph_prefixes(
269+
graph, {str(v.prefix_prefix): str(v.prefix_reference) for v in schema.prefixes.values()}
270+
)
267271
graph.add((base, RDF.type, OWL.Ontology))
268272

269273
# Add main schema elements

packages/linkml/src/linkml/generators/shaclgen.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range
1414
from linkml.generators.shacl.shacl_data_type import ShaclDataType
1515
from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor
16-
from linkml.utils.generator import Generator, shared_arguments
16+
from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments
1717
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName
1818
from linkml_runtime.utils.formatutils import underscore
1919
from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str
@@ -105,6 +105,10 @@ def as_graph(self) -> Graph:
105105

106106
for pfx in self.schema.prefixes.values():
107107
g.bind(str(pfx.prefix_prefix), pfx.prefix_reference)
108+
if self.normalize_prefixes:
109+
normalize_graph_prefixes(
110+
g, {str(v.prefix_prefix): str(v.prefix_reference) for v in self.schema.prefixes.values()}
111+
)
108112

109113
for c in sv.all_classes(imports=not self.exclude_imports).values():
110114

0 commit comments

Comments
 (0)