Skip to content

Commit 60f62c4

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 (defaults changed between 6.x/7.x) - Both http://schema.org/ and https://schema.org/ map to 'schema' (linkml-runtime uses HTTP, W3C prefers HTTPS); likewise for wgs84 - Shared normalize_graph_prefixes() helper used by OWL and SHACL generators — same explicit remap approach as JSON-LD context generator (replaces blunt override=False which dragged in all 29 rdflib defaults) - Two-phase graph normalisation: Phase 1 normalises schema-declared prefixes, Phase 2 cleans up runtime-injected bindings (e.g. metamodel defaults that cause orphaned prefixes like 'schema1') - Collision detection: when the standard prefix name is already user-declared for a different namespace, normalisation is skipped with a warning to prevent silent data loss - Context generator handles three cases: unbound standard prefix, stale binding (different URI), and duplicate binding (same URI) When enabled: - OWL/SHACL generators bind schema prefixes normally, then post-process via normalize_graph_prefixes() to rebind non-standard aliases - ContextGenerator remaps non-standard prefix names to their standard equivalents and applies the remap consistently to CURIE generation 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, and static map integrity. Signed-off-by: jdsika <carlo.van-driesten@bmw.de> Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
1 parent 82d028f commit 60f62c4

6 files changed

Lines changed: 794 additions & 4 deletions

File tree

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

Lines changed: 75 additions & 1 deletion
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, SlotDefinition
2020
from linkml_runtime.linkml_model.types import SHEX
2121
from linkml_runtime.utils.formatutils import camelcase, underscore
@@ -66,6 +66,9 @@ class ContextGenerator(Generator):
6666
frame_root: str | None = None
6767

6868
def __post_init__(self) -> None:
69+
# Must be set before super().__post_init__() because the parent triggers
70+
# the visitor pattern (visit_schema), which accesses _prefix_remap.
71+
self._prefix_remap: dict[str, str] = {}
6972
super().__post_init__()
7073
if self.namespaces is None:
7174
raise TypeError("Schema text must be supplied to context generator. Preparsed schema will not work")
@@ -80,6 +83,10 @@ def __post_init__(self) -> None:
8083
self._local_classes = set(sv.all_classes(imports=False).keys())
8184
self._local_slots = set(sv.all_slots(imports=False).keys())
8285

86+
def add_prefix(self, ncname: str) -> None:
87+
"""Add a prefix, applying well-known prefix normalisation when enabled."""
88+
super().add_prefix(self._prefix_remap.get(ncname, ncname))
89+
8390
def visit_schema(self, base: str | Namespace | None = None, output: str | None = None, **_):
8491
# Add any explicitly declared prefixes
8592
for prefix in self.schema.prefixes.values():
@@ -89,6 +96,68 @@ def visit_schema(self, base: str | Namespace | None = None, output: str | None =
8996
for pfx in self.schema.emit_prefixes:
9097
self.add_prefix(pfx)
9198

99+
# Normalise well-known prefix names when --normalize-prefixes is set.
100+
# If the schema declares a non-standard alias for a namespace that has
101+
# a well-known standard name (e.g. ``sdo`` for
102+
# ``https://schema.org/``), replace the alias with the standard name
103+
# so that generated JSON-LD contexts use the conventional prefix.
104+
#
105+
# Three cases are handled:
106+
# 1. Standard prefix is not yet bound → just rebind from old to new.
107+
# 2. Standard prefix is bound to a *different* URI:
108+
# a. User-declared (in schema.prefixes) → collision, skip with warning.
109+
# b. Runtime default (e.g. linkml-runtime's ``schema: http://…``)
110+
# → remove stale binding, then rebind.
111+
# 3. Standard prefix is already bound to the *same* URI (duplicate)
112+
# → just drop the non-standard alias.
113+
#
114+
# A remap dict is stored for ``_build_element_id`` because
115+
# ``prefix_suffix()`` splits CURIEs on ``:`` without looking up the
116+
# namespace dict.
117+
self._prefix_remap.clear()
118+
if self.normalize_prefixes:
119+
wk = well_known_prefix_map()
120+
for old_pfx in list(self.namespaces):
121+
url = str(self.namespaces[old_pfx])
122+
std_pfx = wk.get(url)
123+
if not std_pfx or std_pfx == old_pfx:
124+
continue
125+
if std_pfx in self.namespaces:
126+
if str(self.namespaces[std_pfx]) != url:
127+
# Case 2: std_pfx is bound to a different URI.
128+
# If the user explicitly declared std_pfx in the schema,
129+
# it is intentional — skip to avoid data loss.
130+
if std_pfx in self.schema.prefixes:
131+
self.logger.warning(
132+
"Prefix collision: cannot rename '%s' to '%s' because '%s' is "
133+
"already declared for <%s>; skipping normalisation for <%s>",
134+
old_pfx,
135+
std_pfx,
136+
std_pfx,
137+
str(self.namespaces[std_pfx]),
138+
url,
139+
)
140+
continue
141+
# Not user-declared (e.g. linkml-runtime default) — safe to remove
142+
self.emit_prefixes.discard(std_pfx)
143+
del self.namespaces[std_pfx]
144+
else:
145+
# Case 3: standard prefix already bound to same URI
146+
# — just drop the non-standard alias
147+
del self.namespaces[old_pfx]
148+
if old_pfx in self.emit_prefixes:
149+
self.emit_prefixes.discard(old_pfx)
150+
self.emit_prefixes.add(std_pfx)
151+
self._prefix_remap[old_pfx] = std_pfx
152+
continue
153+
# Case 1 (or Case 2 after stale removal): bind standard name
154+
self.namespaces[std_pfx] = self.namespaces[old_pfx]
155+
del self.namespaces[old_pfx]
156+
if old_pfx in self.emit_prefixes:
157+
self.emit_prefixes.discard(old_pfx)
158+
self.emit_prefixes.add(std_pfx)
159+
self._prefix_remap[old_pfx] = std_pfx
160+
92161
# Add the default prefix
93162
if self.schema.default_prefix:
94163
dflt = self.namespaces.prefix_for(self.schema.default_prefix)
@@ -310,6 +379,11 @@ def _build_element_id(self, definition: Any, uri: str) -> None:
310379
@return: None
311380
"""
312381
uri_prefix, uri_suffix = self.namespaces.prefix_suffix(uri)
382+
# Apply well-known prefix normalisation (e.g. sdo → schema).
383+
# prefix_suffix() splits CURIEs on ':' without checking the
384+
# namespace dict, so it may return a stale alias.
385+
if uri_prefix and uri_prefix in self._prefix_remap:
386+
uri_prefix = self._prefix_remap[uri_prefix]
313387
is_default_namespace = uri_prefix == self.context_body["@vocab"] or uri_prefix == self.namespaces.prefix_for(
314388
self.context_body["@vocab"]
315389
)

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from linkml import METAMODEL_NAMESPACE_NAME
2121
from linkml._version import __version__
2222
from linkml.utils.deprecation import deprecation_warning
23-
from linkml.utils.generator import Generator, shared_arguments
23+
from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments
2424
from linkml_runtime import SchemaView
2525
from linkml_runtime.linkml_model.meta import (
2626
AnonymousClassExpression,
@@ -233,6 +233,10 @@ def as_graph(self) -> Graph:
233233
self.graph.bind(prefix, self.metamodel.namespaces[prefix])
234234
for pfx in schema.prefixes.values():
235235
self.graph.namespace_manager.bind(pfx.prefix_prefix, URIRef(pfx.prefix_reference))
236+
if self.normalize_prefixes:
237+
normalize_graph_prefixes(
238+
graph, {str(v.prefix_prefix): str(v.prefix_reference) for v in schema.prefixes.values()}
239+
)
236240
graph.add((base, RDF.type, OWL.Ontology))
237241

238242
# 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

packages/linkml/src/linkml/utils/generator.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@
2020
import os
2121
import re
2222
import sys
23+
import types
2324
from collections.abc import Callable, Mapping
2425
from dataclasses import dataclass, field
2526
from functools import lru_cache
2627
from pathlib import Path
27-
from typing import ClassVar, TextIO, Union, cast
28+
from typing import TYPE_CHECKING, ClassVar, TextIO, Union, cast
2829

2930
import click
3031
from click import Argument, Command, Option
@@ -58,6 +59,9 @@
5859
from linkml_runtime.utils.formatutils import camelcase, underscore
5960
from linkml_runtime.utils.namespaces import Namespaces
6061

62+
if TYPE_CHECKING:
63+
from rdflib import Graph
64+
6165
logger = logging.getLogger(__name__)
6266

6367

@@ -78,6 +82,127 @@ def _resolved_metamodel(mergeimports):
7882
return metamodel
7983

8084

85+
def well_known_prefix_map() -> dict[str, str]:
86+
"""Return a mapping from namespace URI to standard prefix name.
87+
88+
Uses a frozen, version-independent map derived from rdflib 7.x curated
89+
defaults (which align with the `prefix.cc <https://prefix.cc>`_ community
90+
consensus registry). The map is **not** computed at runtime from
91+
``Graph().namespaces()`` because those defaults can change across rdflib
92+
releases (they differ between 6.x and 7.x), which would silently alter
93+
generator output.
94+
95+
This allows generators to normalise non-standard prefix aliases
96+
(e.g. ``sdo`` for ``https://schema.org/``) to their conventional names.
97+
98+
Both ``http`` and ``https`` variants of schema.org are included because
99+
the linkml-runtime historically binds ``schema: http://schema.org/``
100+
while rdflib (and the W3C) prefer ``https://schema.org/``.
101+
"""
102+
return dict(_WELL_KNOWN_PREFIX_MAP)
103+
104+
105+
# Frozen, version-independent map: namespace URI → canonical prefix name.
106+
# Source: rdflib 7.x defaults, cross-checked against https://prefix.cc
107+
_WELL_KNOWN_PREFIX_MAP: types.MappingProxyType[str, str] = types.MappingProxyType(
108+
{
109+
"https://brickschema.org/schema/Brick#": "brick",
110+
"http://www.w3.org/ns/csvw#": "csvw",
111+
"http://purl.org/dc/elements/1.1/": "dc",
112+
"http://purl.org/dc/dcam/": "dcam",
113+
"http://www.w3.org/ns/dcat#": "dcat",
114+
"http://purl.org/dc/dcmitype/": "dcmitype",
115+
"http://purl.org/dc/terms/": "dcterms",
116+
"http://usefulinc.com/ns/doap#": "doap",
117+
"http://xmlns.com/foaf/0.1/": "foaf",
118+
"http://www.opengis.net/ont/geosparql#": "geo",
119+
"http://www.w3.org/ns/odrl/2/": "odrl",
120+
"http://www.w3.org/ns/org#": "org",
121+
"http://www.w3.org/2002/07/owl#": "owl",
122+
"http://www.w3.org/ns/dx/prof/": "prof",
123+
"http://www.w3.org/ns/prov#": "prov",
124+
"http://purl.org/linked-data/cube#": "qb",
125+
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
126+
"http://www.w3.org/2000/01/rdf-schema#": "rdfs",
127+
"https://schema.org/": "schema",
128+
"http://schema.org/": "schema", # HTTP variant (linkml-runtime uses this)
129+
"http://www.w3.org/ns/shacl#": "sh",
130+
"http://www.w3.org/2004/02/skos/core#": "skos",
131+
"http://www.w3.org/ns/sosa/": "sosa",
132+
"http://www.w3.org/ns/ssn/": "ssn",
133+
"http://www.w3.org/2006/time#": "time",
134+
"http://purl.org/vocab/vann/": "vann",
135+
"http://rdfs.org/ns/void#": "void",
136+
"https://www.w3.org/2003/01/geo/wgs84_pos#": "wgs",
137+
"http://www.w3.org/2003/01/geo/wgs84_pos#": "wgs", # HTTP variant (W3C canonical)
138+
"http://www.w3.org/XML/1998/namespace": "xml",
139+
"http://www.w3.org/2001/XMLSchema#": "xsd",
140+
}
141+
)
142+
143+
144+
def normalize_graph_prefixes(graph: "Graph", schema_prefixes: dict[str, str]) -> None:
145+
"""Normalise non-standard prefix aliases in an rdflib Graph.
146+
147+
For each prefix bound in *schema_prefixes* (mapping prefix name →
148+
namespace URI), check whether ``well_known_prefix_map()`` knows a
149+
standard name for that URI. If the standard name differs from the
150+
schema-declared name, rebind the namespace to the standard name.
151+
152+
This is the **shared implementation** used by OWL, SHACL, and (via a
153+
different code-path) JSON-LD context generators so that all serialisation
154+
formats agree on prefix names when ``--normalize-prefixes`` is active.
155+
156+
:param graph: rdflib Graph whose namespace bindings should be adjusted.
157+
:param schema_prefixes: mapping of prefix name → namespace URI string,
158+
typically from ``schema.prefixes``.
159+
"""
160+
from rdflib import Namespace
161+
162+
wk = well_known_prefix_map()
163+
164+
# Phase 1: normalise schema-declared prefixes.
165+
for old_pfx, ns_uri in schema_prefixes.items():
166+
ns_str = str(ns_uri)
167+
std_pfx = wk.get(ns_str)
168+
if not std_pfx or std_pfx == old_pfx:
169+
continue
170+
# Collision: the user explicitly declared std_pfx for a different
171+
# namespace — do not clobber their binding.
172+
if std_pfx in schema_prefixes and schema_prefixes[std_pfx] != ns_str:
173+
logger.warning(
174+
"Prefix collision: cannot rename '%s' to '%s' because '%s' is already "
175+
"declared for <%s>; skipping normalisation for <%s>",
176+
old_pfx,
177+
std_pfx,
178+
std_pfx,
179+
schema_prefixes[std_pfx],
180+
ns_str,
181+
)
182+
continue
183+
# Rebind: remove old prefix, add standard prefix.
184+
# ``replace=True`` forces the new prefix even if the prefix name
185+
# is already bound to a different namespace.
186+
graph.bind(std_pfx, Namespace(ns_str), override=True, replace=True)
187+
188+
# Phase 2: normalise runtime-injected bindings (e.g. metamodel defaults).
189+
# The linkml-runtime / rdflib may inject well-known namespaces under
190+
# non-standard prefix names. After Phase 1 rebinds schema-declared
191+
# prefixes, orphaned runtime bindings can appear as ``schema1``, ``dc0``,
192+
# etc. Scan the graph's current bindings and fix any that map to a
193+
# well-known namespace under a non-standard name, provided the standard
194+
# name isn't already claimed by the user for a different namespace.
195+
for pfx, ns in list(graph.namespaces()):
196+
pfx_str, ns_str = str(pfx), str(ns)
197+
std_pfx = wk.get(ns_str)
198+
if not std_pfx or std_pfx == pfx_str:
199+
continue
200+
# Same collision check as Phase 1: respect user-declared prefixes.
201+
if std_pfx in schema_prefixes and schema_prefixes[std_pfx] != ns_str:
202+
continue
203+
graph.bind(std_pfx, Namespace(ns_str), override=True, replace=True)
204+
205+
81206
@dataclass
82207
class Generator(metaclass=abc.ABCMeta):
83208
"""
@@ -180,6 +305,12 @@ class Generator(metaclass=abc.ABCMeta):
180305
stacktrace: bool = False
181306
"""True means print stack trace, false just error message"""
182307

308+
normalize_prefixes: bool = False
309+
"""True means normalise non-standard prefix aliases to well-known names
310+
from the static ``_WELL_KNOWN_PREFIX_MAP`` (derived from rdflib 7.x
311+
defaults / prefix.cc consensus). E.g. ``sdo`` → ``schema`` for
312+
``https://schema.org/``."""
313+
183314
include: str | Path | SchemaDefinition | None = None
184315
"""If set, include extra schema outside of the imports mechanism"""
185316

@@ -986,6 +1117,16 @@ def decorator(f: Command) -> Command:
9861117
callback=stacktrace_callback,
9871118
)
9881119
)
1120+
f.params.append(
1121+
Option(
1122+
("--normalize-prefixes/--no-normalize-prefixes",),
1123+
default=False,
1124+
show_default=True,
1125+
help="Normalise non-standard prefix aliases to rdflib's curated default names "
1126+
"(e.g. sdo → schema for https://schema.org/). "
1127+
"Supported by OWL, SHACL, and JSON-LD Context generators.",
1128+
)
1129+
)
9891130

9901131
return f
9911132

0 commit comments

Comments
 (0)