Skip to content

Commit e891c43

Browse files
committed
feat(shaclgen): add --exclude-external-imports flag
Backport the exclude_external_imports capability from ContextGenerator to ShaclGenerator. When enabled, shapes from URL-based external vocabulary imports (http:// or https://) are excluded while local file imports and linkml standard imports are kept. This allows schemas that extend external ontologies to generate only their own shapes without post-processing namespace filtering. Signed-off-by: jdsika <carlo.van-driesten@bmw.de>
1 parent 5df92ed commit e891c43

2 files changed

Lines changed: 148 additions & 1 deletion

File tree

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import os
33
from collections.abc import Callable
4-
from dataclasses import dataclass
4+
from dataclasses import dataclass, field
55

66
import click
77
from jsonasobj2 import JsonObj, as_dict
@@ -16,6 +16,7 @@
1616
from linkml.utils.generator import Generator, shared_arguments
1717
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName
1818
from linkml_runtime.utils.formatutils import underscore
19+
from linkml_runtime.utils.schemaview import SchemaView
1920
from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str
2021

2122
logger = logging.getLogger(__name__)
@@ -62,6 +63,15 @@ class ShaclGenerator(Generator):
6263
"""True means include all class / slot / type annotations in generated Node or Property shapes"""
6364
exclude_imports: bool = False
6465
"""If True, elements from imported ontologies won't be included in the generator's output"""
66+
exclude_external_imports: bool = False
67+
"""If True, elements from URL-based external vocabulary imports are excluded.
68+
69+
Local file imports and linkml standard imports are kept. This is useful
70+
when generating shapes for a schema that extends an external ontology —
71+
only shapes defined in the local schema and its file-based imports are
72+
emitted, while shapes from ``http(s)://``-based imports are suppressed.
73+
"""
74+
_external_classes: set | None = field(default=None, repr=False)
6575
use_class_uri_names: bool = True
6676
"""
6777
Control how SHACL shape URIs are generated.
@@ -84,6 +94,27 @@ class ShaclGenerator(Generator):
8494
def __post_init__(self) -> None:
8595
super().__post_init__()
8696
self.generate_header()
97+
if self.exclude_external_imports:
98+
sv = self.schemaview
99+
self._external_classes = self._collect_external_classes(sv)
100+
101+
@staticmethod
102+
def _collect_external_classes(sv: SchemaView) -> set[str]:
103+
"""Identify classes from URL-based external vocabulary imports.
104+
105+
Walks the SchemaView ``schema_map`` (populated by ``imports_closure``)
106+
and collects class names from schemas whose import key starts with
107+
``http://`` or ``https://``. Local file imports and ``linkml:``
108+
standard imports are left untouched.
109+
"""
110+
sv.imports_closure()
111+
external_classes: set[str] = set()
112+
for schema_key, schema_def in sv.schema_map.items():
113+
if schema_key == sv.schema.name:
114+
continue
115+
if schema_key.startswith("http://") or schema_key.startswith("https://"):
116+
external_classes.update(schema_def.classes.keys())
117+
return external_classes
87118

88119
def generate_header(self) -> str:
89120
out = f"\n# metamodel_version: {self.schema.metamodel_version}"
@@ -107,6 +138,8 @@ def as_graph(self) -> Graph:
107138
g.bind(str(pfx.prefix_prefix), pfx.prefix_reference)
108139

109140
for c in sv.all_classes(imports=not self.exclude_imports).values():
141+
if self.exclude_external_imports and self._external_classes and c.name in self._external_classes:
142+
continue
110143

111144
def shape_pv(p, v):
112145
if v is not None:
@@ -508,6 +541,14 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None:
508541
help="Use --exclude-imports to exclude imported elements from the generated SHACL shapes. This is useful when "
509542
"extending a substantial ontology to avoid large output files.",
510543
)
544+
@click.option(
545+
"--exclude-external-imports/--include-external-imports",
546+
default=False,
547+
show_default=True,
548+
help="Use --exclude-external-imports to exclude elements from URL-based external vocabulary imports while keeping "
549+
"local file imports and linkml standard imports. Useful when extending an external ontology and only shapes for "
550+
"the local schema are needed.",
551+
)
511552
@click.option(
512553
"--use-class-uri-names/--use-native-names",
513554
default=True,

tests/linkml/test_generators/test_shaclgen.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,3 +1160,109 @@ def test_nodeidentifier_range_produces_blank_node_or_iri():
11601160
uri_ref = props["https://example.org/uriRef"]
11611161
uri_kinds = list(g.objects(uri_ref, SH.nodeKind))
11621162
assert SH.IRI in uri_kinds, f"Expected sh:IRI for uri, got {uri_kinds}"
1163+
1164+
1165+
def test_exclude_external_imports(tmp_path):
1166+
"""With --exclude-external-imports, shapes from URL-based external
1167+
vocabulary imports are excluded, while local file imports are kept.
1168+
"""
1169+
import textwrap
1170+
1171+
ext_dir = tmp_path / "ext"
1172+
ext_dir.mkdir()
1173+
(ext_dir / "external_vocab.yaml").write_text(
1174+
textwrap.dedent("""\
1175+
id: https://example.org/external-vocab
1176+
name: external_vocab
1177+
default_prefix: ext
1178+
prefixes:
1179+
linkml: https://w3id.org/linkml/
1180+
ext: https://example.org/external-vocab/
1181+
imports:
1182+
- linkml:types
1183+
slots:
1184+
issuer:
1185+
slot_uri: ext:issuer
1186+
range: string
1187+
classes:
1188+
ExternalCredential:
1189+
class_uri: ext:ExternalCredential
1190+
slots:
1191+
- issuer
1192+
"""),
1193+
encoding="utf-8",
1194+
)
1195+
local_dir = tmp_path / "local"
1196+
local_dir.mkdir()
1197+
(local_dir / "local_types.yaml").write_text(
1198+
textwrap.dedent("""\
1199+
id: https://example.org/local-types
1200+
name: local_types
1201+
default_prefix: loc
1202+
prefixes:
1203+
linkml: https://w3id.org/linkml/
1204+
loc: https://example.org/local-types/
1205+
imports:
1206+
- linkml:types
1207+
slots:
1208+
localField:
1209+
slot_uri: loc:localField
1210+
range: string
1211+
classes:
1212+
LocalType:
1213+
class_uri: loc:LocalType
1214+
slots:
1215+
- localField
1216+
"""),
1217+
encoding="utf-8",
1218+
)
1219+
(tmp_path / "main.yaml").write_text(
1220+
textwrap.dedent("""\
1221+
id: https://example.org/main
1222+
name: main
1223+
default_prefix: main
1224+
prefixes:
1225+
linkml: https://w3id.org/linkml/
1226+
main: https://example.org/main/
1227+
ext: https://example.org/external-vocab/
1228+
loc: https://example.org/local-types/
1229+
imports:
1230+
- linkml:types
1231+
- https://example.org/external-vocab
1232+
- ./local/local_types
1233+
slots:
1234+
localName:
1235+
slot_uri: main:localName
1236+
range: string
1237+
classes:
1238+
MainThing:
1239+
class_uri: main:MainThing
1240+
slots:
1241+
- localName
1242+
"""),
1243+
encoding="utf-8",
1244+
)
1245+
1246+
importmap = {"https://example.org/external-vocab": str(ext_dir / "external_vocab")}
1247+
1248+
shacl_text = ShaclGenerator(
1249+
str(tmp_path / "main.yaml"),
1250+
exclude_external_imports=True,
1251+
importmap=importmap,
1252+
base_dir=str(tmp_path),
1253+
).serialize()
1254+
1255+
g = rdflib.Graph()
1256+
g.parse(data=shacl_text)
1257+
1258+
shapes = set(g.subjects(RDF.type, SH.NodeShape))
1259+
shape_strs = {str(s) for s in shapes}
1260+
1261+
# Local schema class must be present
1262+
assert any("MainThing" in s for s in shape_strs), f"Local class 'MainThing' missing, got: {shape_strs}"
1263+
# Local file import class must be present
1264+
assert any("LocalType" in s for s in shape_strs), f"Local file import 'LocalType' missing, got: {shape_strs}"
1265+
# External vocab class must NOT be present
1266+
assert not any("ExternalCredential" in s for s in shape_strs), (
1267+
f"External class 'ExternalCredential' should be excluded, got: {shape_strs}"
1268+
)

0 commit comments

Comments
 (0)