Merge pull request #1124 from fabio-rovai/shacl-targets-and-domains

Target the namespace the data uses, and stop attaching domain-less properties to every class (#1104, #1105)
This commit is contained in:
Mohd Kaif
2026-08-21 17:28:07 +05:30
committed by GitHub
4 changed files with 522 additions and 26 deletions
+12
View File
@@ -203,6 +203,8 @@ class OntologyEngine:
include_inherited: bool = True,
severity: str = "Violation",
quality_tier: str = "standard",
target_namespace: Optional[str] = None,
attach_domainless_properties: bool = False,
validate_output: bool = False,
**options,
) -> str:
@@ -217,6 +219,12 @@ class OntologyEngine:
include_inherited: Propagate parent class property shapes to child shapes.
severity: Default severity — "Violation", "Warning", or "Info".
quality_tier: Constraint completeness — "basic", "standard" (default), "strict".
target_namespace: Namespace sh:targetClass and sh:path are expanded in,
when the ontology supplies no absolute IRIs. Separate from base_uri,
which says where the shape resources themselves live.
attach_domainless_properties: Attach properties with no declared domain
to every node shape. Off by default: it states a constraint the
ontology does not.
validate_output: Syntax-check output via rdflib before returning.
Returns:
@@ -236,12 +244,16 @@ class OntologyEngine:
or (ns.get("base_uri") if isinstance(ns, dict) else None)
or "https://semantica.dev/shapes/"
)
# These two are constructor-only on SHACLGenerator, so forwarding
# them through generate(**options) silently dropped them.
generator = SHACLGenerator(
base_uri=resolved_base,
shapes_uri=shapes_uri,
include_inherited=include_inherited,
severity=severity,
quality_tier=quality_tier,
target_namespace=target_namespace,
attach_domainless_properties=attach_domainless_properties,
)
graph = generator.generate(ontology, **options)
self.progress.update_tracking(tracking_id, message="Serializing SHACL graph")
+157 -16
View File
@@ -30,6 +30,7 @@ Author: Semantica Contributors
License: MIT
"""
import re
from dataclasses import dataclass, field, replace as dataclass_replace
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -781,6 +782,13 @@ class SHACLGraph:
shapes_uri: str
node_shapes: List[NodeShape] = field(default_factory=list)
prefixes: Dict[str, str] = field(default_factory=dict)
# Bare names mapped to the absolute IRI the data uses for them. Shapes are
# indexed internally by name; this is what those names expand to at
# serialisation time (#1104). Classes and properties are kept apart because
# a property may legitimately share a class's name, and a single map would
# silently give it the class's IRI.
class_iris: Dict[str, str] = field(default_factory=dict)
property_iris: Dict[str, str] = field(default_factory=dict)
class SHACLGenerator:
@@ -813,8 +821,23 @@ class SHACLGenerator:
include_inherited: bool = True,
severity: str = "Violation",
quality_tier: str = "standard",
target_namespace: Optional[str] = None,
attach_domainless_properties: bool = False,
config: Optional[Dict[str, Any]] = None,
):
"""
Args:
base_uri: Namespace the shape resources themselves live in.
target_namespace: Namespace the terms being validated live in, used
to expand sh:targetClass and sh:path when the ontology does not
supply absolute IRIs. This is deliberately separate from
base_uri: shapes that target their own namespace match nothing,
and pySHACL reports that as conforming (#1104).
attach_domainless_properties: When True, a property with no declared
domain is attached to every node shape, which is the pre-0.6.6
behaviour. It invents a constraint the ontology never stated, so
it is off by default (#1105).
"""
self.logger = get_logger("ontology_shacl")
self.progress_tracker = get_progress_tracker()
self.base_uri = base_uri.rstrip("/") + "/"
@@ -822,6 +845,8 @@ class SHACLGenerator:
self.include_inherited = include_inherited
self.severity = severity
self.quality_tier = quality_tier
self.target_namespace = target_namespace
self.attach_domainless_properties = attach_domainless_properties
self.config = config or {}
# ── Public API ────────────────────────────────────────────────────────────
@@ -859,10 +884,15 @@ class SHACLGenerator:
"ex": base_uri,
}
target_ns = self._resolve_target_namespace(ontology, base_uri)
prefixes["ex"] = target_ns
graph = SHACLGraph(
base_uri=base_uri,
shapes_uri=self.shapes_uri,
prefixes=prefixes,
class_iris=self._build_term_index(classes, target_ns),
property_iris=self._build_term_index(properties, target_ns),
)
self.progress_tracker.update_tracking(tracking_id, message="Generating node shapes")
@@ -931,6 +961,80 @@ class SHACLGenerator:
# ── Internal pipeline stages ──────────────────────────────────────────────
_DEFAULT_TARGET_NAMESPACE = "https://semantica.dev/ns#"
@staticmethod
def _is_absolute_iri(value: Any) -> bool:
return isinstance(value, str) and bool(
re.match(r"^[A-Za-z][A-Za-z0-9+.\-]*:", value.strip())
)
@staticmethod
def _join_iri(base: str, local: str) -> str:
separator = "" if base.endswith(("#", "/", ":")) else "#"
return f"{base}{separator}{local}"
def _resolve_target_namespace(self, ontology: Dict[str, Any], base_uri: str) -> str:
"""
Decide which namespace sh:targetClass and sh:path are expanded in.
base_uri says where the shapes live. It is only the right answer here
when the ontology declared it, meaning shapes and terms deliberately
share a namespace. Falling back to the shapes namespace produces shapes
that target terms no data graph uses.
"""
if self.target_namespace:
return self.target_namespace
namespace = ontology.get("namespace")
if isinstance(namespace, dict) and namespace.get("base_uri"):
return str(namespace["base_uri"])
# An IRI already carried by a term is the most reliable evidence of
# where the data lives, so prefer it over any configured default.
for terms in (ontology.get("classes"), ontology.get("properties")):
for term in terms or []:
if not isinstance(term, dict):
continue
for key in ("uri", "iri", "id"):
value = term.get(key)
if self._is_absolute_iri(value):
value = value.strip()
cut = max(value.rfind("#"), value.rfind("/"))
if cut != -1:
return value[: cut + 1]
if ontology.get("uri") and self._is_absolute_iri(ontology["uri"]):
return str(ontology["uri"])
if base_uri != self.base_uri:
return base_uri
return self._DEFAULT_TARGET_NAMESPACE
def _build_term_index(
self, terms: List[Dict[str, Any]], target_ns: str
) -> Dict[str, str]:
"""Map each term's name to the absolute IRI it expands to."""
index: Dict[str, str] = {}
for term in terms or []:
if not isinstance(term, dict):
continue
name = term.get("name")
if not isinstance(name, str) or not name.strip():
continue
name = name.strip()
iri = ""
for key in ("uri", "iri", "id"):
value = term.get(key)
if isinstance(value, str) and value.strip():
value = value.strip()
iri = value if self._is_absolute_iri(value) else self._join_iri(target_ns, value)
break
index.setdefault(name, iri or self._join_iri(target_ns, name))
return index
def _build_class_index(
self, classes: List[Dict[str, Any]]
) -> Dict[str, Dict[str, Any]]:
@@ -979,13 +1083,23 @@ class SHACLGenerator:
self.logger.debug(
f"Property '{pname}' domain '{d}' has no matching node shape — skipped"
)
else:
# No domain declared → attach to all shapes
self.logger.debug(
f"Property '{pname}' has no domain — attaching to all node shapes"
elif self.attach_domainless_properties:
self.logger.warning(
f"Property '{pname}' declares no domain and is being attached "
"to every node shape because attach_domainless_properties is "
"set. This states a constraint the ontology does not."
)
for node_shape in graph.node_shapes:
node_shape.property_shapes.append(self._build_property_shape(prop))
else:
# Attaching here would state a constraint the ontology does not.
# With minCount 1 that invalidates every instance of every
# class, so the property is left unattached (#1105).
self.logger.warning(
f"Property '{pname}' declares no domain, so it is not attached "
"to any node shape. Declare a domain, or pass "
"attach_domainless_properties=True to restore the old behaviour."
)
def _build_property_shape(self, prop: Dict[str, Any]) -> PropertyShape:
ptype = prop.get("type", "")
@@ -1065,13 +1179,40 @@ class SHACLGenerator:
def _prefix_decls(self, graph: SHACLGraph) -> str:
return "\n".join(f"@prefix {p}: <{u}> ." for p, u in sorted(graph.prefixes.items()))
def _uri(self, graph: SHACLGraph, local: str) -> str:
"""Return a compact URI reference; fall back to ex:local for bare names."""
def _term_iri(self, graph: SHACLGraph, local: str, kind: str = "class") -> str:
"""
Resolve a class or property name to the absolute IRI the data uses.
Every serializer goes through here. Turtle alone was corrected at first,
which left JSON-LD and N-Triples still pasting names onto the shapes
namespace, so the shapes they produced went on matching nothing (#1104).
`kind` selects the index: a property may share a class's name, and the
two can carry different IRIs.
"""
if local.startswith("http://") or local.startswith("https://"):
return f"<{local}>"
return local
index = graph.property_iris if kind == "property" else graph.class_iris
resolved = index.get(local)
if resolved:
return resolved
# Fall back to the other index before giving up: sh:class names a class,
# but a caller may pass a term only registered on the other side.
other = graph.class_iris if kind == "property" else graph.property_iris
resolved = other.get(local)
if resolved:
return resolved
if ":" in local:
return local
return f"ex:{local}"
separator = "" if graph.base_uri.endswith(("#", "/", ":")) else "#"
return f"{graph.base_uri}{separator}{local}"
def _uri(self, graph: SHACLGraph, local: str, kind: str = "class") -> str:
"""Turtle-facing wrapper: the resolved IRI, in angle brackets."""
resolved = self._term_iri(graph, local, kind)
if resolved.startswith(("http://", "https://", "urn:")):
return f"<{resolved}>"
return resolved
def _serialize_turtle(self, graph: SHACLGraph) -> str:
lines = [self._prefix_decls(graph), ""]
@@ -1098,11 +1239,11 @@ class SHACLGenerator:
is_last = i == len(node_shape.property_shapes) - 1
terminator = " ." if is_last else " ;"
parts = [" sh:property ["]
parts.append(f" sh:path {self._uri(graph, ps.path)} ;")
parts.append(f' sh:path {self._uri(graph, ps.path, "property")} ;')
if ps.datatype:
parts.append(f" sh:datatype {ps.datatype} ;")
if ps.class_:
parts.append(f" sh:class {self._uri(graph, ps.class_)} ;")
parts.append(f' sh:class {self._uri(graph, ps.class_, "class")} ;')
if ps.min_count is not None:
parts.append(f" sh:minCount {ps.min_count} ;")
if ps.max_count is not None:
@@ -1143,7 +1284,7 @@ class SHACLGenerator:
node: Dict[str, Any] = {
"@id": shape_id,
"@type": "sh:NodeShape",
"sh:targetClass": {"@id": f"{graph.base_uri}{node_shape.target_class}"},
"sh:targetClass": {"@id": self._term_iri(graph, node_shape.target_class, "class")},
}
if node_shape.name:
node["sh:name"] = node_shape.name
@@ -1156,7 +1297,7 @@ class SHACLGenerator:
props = []
for ps in node_shape.property_shapes:
p: Dict[str, Any] = {
"sh:path": {"@id": f"{graph.base_uri}{ps.path}"}
"sh:path": {"@id": self._term_iri(graph, ps.path, "property")}
}
if ps.datatype:
dt = ps.datatype.replace(
@@ -1164,7 +1305,7 @@ class SHACLGenerator:
)
p["sh:datatype"] = {"@id": dt}
if ps.class_:
p["sh:class"] = {"@id": f"{graph.base_uri}{ps.class_}"}
p["sh:class"] = {"@id": self._term_iri(graph, ps.class_, "class")}
if ps.min_count is not None:
p["sh:minCount"] = ps.min_count
if ps.max_count is not None:
@@ -1197,7 +1338,7 @@ class SHACLGenerator:
for i, node_shape in enumerate(graph.node_shapes):
shape_uri = f"<{graph.base_uri}{node_shape.target_class}Shape>"
class_uri = f"<{graph.base_uri}{node_shape.target_class}>"
class_uri = f'<{self._term_iri(graph, node_shape.target_class, "class")}>'
t(shape_uri, f"<{RDF}type>", f"<{SHACL}NodeShape>")
t(shape_uri, f"<{SHACL}targetClass>", class_uri)
if node_shape.name:
@@ -1212,13 +1353,13 @@ class SHACLGenerator:
for j, ps in enumerate(node_shape.property_shapes):
bnode = f"_:ps{i}_{j}"
t(shape_uri, f"<{SHACL}property>", bnode)
prop_uri = f"<{graph.base_uri}{ps.path}>"
prop_uri = f'<{self._term_iri(graph, ps.path, "property")}>'
t(bnode, f"<{SHACL}path>", prop_uri)
if ps.datatype:
dt_uri = ps.datatype.replace("xsd:", XSD)
t(bnode, f"<{SHACL}datatype>", f"<{dt_uri}>")
if ps.class_:
t(bnode, f"<{SHACL}class>", f"<{graph.base_uri}{ps.class_}>")
t(bnode, f"<{SHACL}class>", f'<{self._term_iri(graph, ps.class_, "class")}>')
if ps.min_count is not None:
t(bnode, f"<{SHACL}minCount>", f'"{ps.min_count}"^^<{XSD}integer>')
if ps.max_count is not None:
+23 -10
View File
@@ -293,20 +293,33 @@ class TestSHACLHierarchicalAndValidation(unittest.TestCase):
f"Duplicate paths in {node_shape.target_class}: {paths}")
# 21
def test_no_domain_property_attaches_to_all_shapes(self):
onto = {
"classes": [{"name": "A"}, {"name": "B"}],
"properties": [
{"name": "globalProp", "type": "datatype", "range": "string"}
# no domain
],
}
gen = self._make_gen()
graph = gen.generate(onto)
_NO_DOMAIN_ONTOLOGY = {
"classes": [{"name": "A"}, {"name": "B"}],
"properties": [
{"name": "globalProp", "type": "datatype", "range": "string"}
# no domain
],
}
def test_no_domain_property_attaches_to_all_shapes_when_opted_in(self):
"""attach_domainless_properties=True keeps the pre-0.6.6 behaviour."""
gen = self._make_gen(attach_domainless_properties=True)
graph = gen.generate(self._NO_DOMAIN_ONTOLOGY)
for node_shape in graph.node_shapes:
paths = {ps.path for ps in node_shape.property_shapes}
self.assertIn("globalProp", paths)
def test_no_domain_property_is_not_attached_by_default(self):
"""
Attaching states a constraint the ontology does not (#1105). This test
previously asserted the opposite, which pinned the defect in place.
"""
gen = self._make_gen()
graph = gen.generate(self._NO_DOMAIN_ONTOLOGY)
for node_shape in graph.node_shapes:
paths = {ps.path for ps in node_shape.property_shapes}
self.assertNotIn("globalProp", paths)
# 22
def test_empty_classes_produces_no_shapes(self):
gen = self._make_gen()
@@ -0,0 +1,330 @@
"""
Regression tests for #1104 and #1105.
#1104: SHACLGenerator used one namespace for two different jobs. `base_uri`
names where the shape resources live, and it was also used to expand every
sh:targetClass and sh:path. With the default `https://semantica.dev/shapes/`
that made shapes target `.../shapes/Person`, while data carries
`.../ns#Person` or the ontology's own class IRI. The shapes matched nothing.
pySHACL then reported conforms=True, because a shape with no focus nodes is
vacuously satisfied, so the mismatch was invisible to the validator the
package ships with.
#1105: a property with no declared domain was attached to every node shape,
which invents a constraint the ontology never stated. With minCount 1 that
makes every instance of every class invalid.
These tests validate real data through pySHACL rather than reading the shapes
text, so a shape that targets nothing cannot pass by being ignored.
"""
import pytest
rdflib = pytest.importorskip("rdflib")
pyshacl = pytest.importorskip("pyshacl")
from rdflib import Graph, RDF, Namespace # noqa: E402
from semantica.ontology.ontology_generator import SHACLGenerator # noqa: E402
SH = Namespace("http://www.w3.org/ns/shacl#")
ONTOLOGY_NS = "https://example.org/onto#"
SHAPES_NS = "https://semantica.dev/shapes/"
def _ontology(*, declare_namespace: bool, carry_class_uris: bool):
"""
Build the same ontology in the shapes the generator can hand over.
A generated ontology carries class URIs; a hand written one often carries
only names, and only sometimes declares a namespace. All of them have to
produce shapes that match the data.
"""
def class_def(name):
entry = {"name": name, "label": name}
if carry_class_uris:
entry["uri"] = ONTOLOGY_NS + name
return entry
def prop_def(name, **extra):
entry = {"name": name, "type": "datatype", "range": "string", **extra}
if carry_class_uris:
entry["uri"] = ONTOLOGY_NS + name
return entry
ontology = {
"classes": [class_def("Person"), class_def("Organization")],
"properties": [
prop_def("fullName", domain="Person", required=True),
# No domain. The ontology never says which class this belongs to.
prop_def("sourceDocument", required=True),
],
}
if declare_namespace:
ontology["namespace"] = {"base_uri": ONTOLOGY_NS}
return ontology
SHAPES = [
pytest.param(True, True, id="namespace+uris"),
pytest.param(True, False, id="namespace-only"),
pytest.param(False, True, id="uris-only"),
]
# A Person with no fullName. This violates the shape the ontology does state.
VIOLATING_DATA = f"""
@prefix ex: <{ONTOLOGY_NS}> .
ex:alice a ex:Person .
"""
# A Person that satisfies every constraint the ontology actually declares.
CONFORMING_DATA = f"""
@prefix ex: <{ONTOLOGY_NS}> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:bob a ex:Person ;
ex:fullName "Bob Smith"^^xsd:string .
"""
def _shapes_graph(ontology, **kwargs):
generator = SHACLGenerator(**kwargs)
graph = generator.generate(ontology)
shapes = Graph()
shapes.parse(data=generator.serialize(graph, "turtle"), format="turtle")
return shapes
def _validate(data_ttl, shapes_graph):
data = Graph()
data.parse(data=data_ttl, format="turtle")
conforms, _, text = pyshacl.validate(
data, shacl_graph=shapes_graph, inference="none", advanced=True
)
return conforms, text
@pytest.mark.parametrize("declare_namespace,carry_class_uris", SHAPES)
def test_target_class_names_a_class_the_data_can_instantiate(declare_namespace, carry_class_uris):
shapes = _shapes_graph(_ontology(
declare_namespace=declare_namespace, carry_class_uris=carry_class_uris
))
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
assert targets == {ONTOLOGY_NS + "Person", ONTOLOGY_NS + "Organization"}, targets
assert not any(t.startswith(SHAPES_NS) for t in targets), (
f"shapes still target their own shapes namespace: {targets}"
)
@pytest.mark.parametrize("declare_namespace,carry_class_uris", SHAPES)
def test_property_path_names_a_predicate_the_data_uses(declare_namespace, carry_class_uris):
shapes = _shapes_graph(_ontology(
declare_namespace=declare_namespace, carry_class_uris=carry_class_uris
))
paths = {str(o) for o in shapes.objects(None, SH.path)}
assert paths, "no sh:path emitted at all"
for path in paths:
assert path.startswith(ONTOLOGY_NS), path
@pytest.mark.parametrize("declare_namespace,carry_class_uris", SHAPES)
def test_a_real_violation_is_actually_reported(declare_namespace, carry_class_uris):
"""The killer case. Shapes that match nothing make pySHACL return conforms=True."""
shapes = _shapes_graph(_ontology(
declare_namespace=declare_namespace, carry_class_uris=carry_class_uris
))
conforms, text = _validate(VIOLATING_DATA, shapes)
assert not conforms, (
"a Person with no fullName was reported as conforming, which means the "
f"shapes matched no focus nodes:\n{text}"
)
assert "fullName" in text
@pytest.mark.parametrize("declare_namespace,carry_class_uris", SHAPES)
def test_conforming_data_still_conforms(declare_namespace, carry_class_uris):
shapes = _shapes_graph(_ontology(
declare_namespace=declare_namespace, carry_class_uris=carry_class_uris
))
conforms, text = _validate(CONFORMING_DATA, shapes)
assert conforms, text
def test_default_target_namespace_is_the_vocabulary_not_the_shapes_namespace():
"""With nothing declared anywhere, targets must not land in the shapes namespace."""
ontology = {
"classes": [{"name": "Person", "label": "Person"}],
"properties": [{"name": "fullName", "type": "datatype", "range": "string",
"domain": "Person", "required": True}],
}
shapes = _shapes_graph(ontology)
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
assert targets, "no sh:targetClass emitted at all"
for target in targets:
assert not target.startswith(SHAPES_NS), target
def test_shape_resources_are_distinct_from_the_classes_they_target():
shapes = _shapes_graph(_ontology(declare_namespace=True, carry_class_uris=True))
node_shapes = {str(s) for s in shapes.subjects(RDF.type, SH.NodeShape)}
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
assert node_shapes, "no node shapes emitted"
assert node_shapes.isdisjoint(targets), (
f"a shape and the class it targets are the same resource: {node_shapes & targets}"
)
# ── #1105 ────────────────────────────────────────────────────────────────────
def test_domainless_property_is_not_asserted_on_every_class():
"""minCount 1 on a domain-less property invalidates every instance of every class."""
generator = SHACLGenerator()
graph = generator.generate(_ontology(declare_namespace=True, carry_class_uris=True))
carriers = [
shape.target_class
for shape in graph.node_shapes
for prop in shape.property_shapes
if prop.path.endswith("sourceDocument")
]
assert carriers == [], f"a property with no declared domain was attached to {carriers}"
def test_domainless_property_does_not_invalidate_conforming_data():
shapes = _shapes_graph(_ontology(declare_namespace=True, carry_class_uris=True))
conforms, text = _validate(CONFORMING_DATA, shapes)
assert conforms, f"invented a constraint the ontology never declared:\n{text}"
assert "sourceDocument" not in text
def test_domainless_attachment_is_available_as_an_explicit_opt_in():
"""The old behaviour stays reachable for anyone who relied on it."""
generator = SHACLGenerator(attach_domainless_properties=True)
graph = generator.generate(_ontology(declare_namespace=True, carry_class_uris=True))
carriers = {
shape.target_class
for shape in graph.node_shapes
for prop in shape.property_shapes
if prop.path.endswith("sourceDocument")
}
assert len(carriers) == len(graph.node_shapes)
# ── Review findings on the first revision of this fix ────────────────────────
@pytest.mark.parametrize("fmt,parse_as", [
("turtle", "turtle"), ("n-triples", "nt"), ("json-ld", "json-ld"),
])
def test_every_format_targets_the_ontology_namespace(fmt, parse_as):
"""
The first revision fixed Turtle alone. JSON-LD and N-Triples went on
pasting names onto the shapes namespace, so two of the three formats still
produced shapes that matched nothing.
"""
generator = SHACLGenerator()
graph = generator.generate(_ontology(declare_namespace=False, carry_class_uris=True))
shapes = Graph()
shapes.parse(data=generator.serialize(graph, fmt), format=parse_as)
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
assert targets == {ONTOLOGY_NS + "Person", ONTOLOGY_NS + "Organization"}, targets
assert not any(t.startswith(SHAPES_NS) for t in targets), targets
@pytest.mark.parametrize("fmt,parse_as", [
("turtle", "turtle"), ("n-triples", "nt"), ("json-ld", "json-ld"),
])
def test_every_format_reports_a_real_violation(fmt, parse_as):
generator = SHACLGenerator()
graph = generator.generate(_ontology(declare_namespace=False, carry_class_uris=True))
shapes = Graph()
shapes.parse(data=generator.serialize(graph, fmt), format=parse_as)
conforms, text = _validate(VIOLATING_DATA, shapes)
assert not conforms, f"{fmt} shapes matched no focus nodes:\n{text}"
def test_a_property_sharing_a_class_name_keeps_its_own_iri():
"""One name-keyed map gave the property the class's IRI, so sh:path validated
the wrong predicate."""
ontology = {
"classes": [{"name": "Account", "uri": ONTOLOGY_NS + "Account"}],
"properties": [
{
"name": "Account",
"uri": ONTOLOGY_NS + "accountNumber",
"type": "datatype",
"range": "string",
"domain": "Account",
"required": True,
}
],
}
shapes = _shapes_graph(ontology)
paths = {str(o) for o in shapes.objects(None, SH.path)}
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
assert paths == {ONTOLOGY_NS + "accountNumber"}, paths
assert targets == {ONTOLOGY_NS + "Account"}, targets
def test_sh_class_resolves_to_the_class_namespace():
ontology = {
"classes": [
{"name": "Person", "uri": ONTOLOGY_NS + "Person"},
{"name": "Organization", "uri": ONTOLOGY_NS + "Organization"},
],
"properties": [
{
"name": "worksAt",
"uri": ONTOLOGY_NS + "worksAt",
"type": "object",
"range": "Organization",
"domain": "Person",
}
],
}
shapes = _shapes_graph(ontology)
classes = {str(o) for o in shapes.objects(None, SH["class"])}
assert classes == {ONTOLOGY_NS + "Organization"}, classes
def test_the_engine_forwards_the_new_options():
"""to_shacl passed them through generate(**options), which never reads them."""
from semantica.ontology.engine import OntologyEngine
ontology = _ontology(declare_namespace=False, carry_class_uris=False)
turtle = OntologyEngine().to_shacl(ontology, target_namespace="https://forwarded.example/ns#")
shapes = Graph()
shapes.parse(data=turtle, format="turtle")
targets = {str(o) for o in shapes.objects(None, SH.targetClass)}
assert all(t.startswith("https://forwarded.example/ns#") for t in targets), targets
attached = OntologyEngine().to_shacl(ontology, attach_domainless_properties=True)
assert "sourceDocument" in attached
default = OntologyEngine().to_shacl(ontology)
assert "sourceDocument" not in default
def test_the_opt_in_warns_rather_than_whispering(caplog):
import logging
with caplog.at_level(logging.WARNING):
SHACLGenerator(attach_domainless_properties=True).generate(
_ontology(declare_namespace=True, carry_class_uris=True)
)
messages = " ".join(record.getMessage() for record in caplog.records)
assert "sourceDocument" in messages