mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user