From 981c9d920896aa0d742369d0068c614370a8c222 Mon Sep 17 00:00:00 2001 From: FABIOTESS Date: Wed, 19 Aug 2026 17:16:42 +0100 Subject: [PATCH 1/2] fix(ontology): target the namespace the data uses, and stop inventing constraints (#1104, #1105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1104 — SHACLGenerator used one namespace for two jobs. `base_uri` says 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 , while data carries the ontology's own class IRI or the semantica:ns# vocabulary. The shapes matched nothing. That failure is silent. A shape with no focus nodes is vacuously satisfied, so pySHACL reports conforms=True on data that plainly breaks the stated constraints. The shipped validator agrees the file is fine. The two namespaces are now separate. `target_namespace` resolves in this order: an explicit argument, the ontology's declared namespace, the namespace of any absolute IRI a term already carries, the ontology URI, and finally the vocabulary namespace the package ships rather than the shapes namespace. Every class and property name is indexed to the IRI it expands to, and `_uri` resolves through that index, so shapes always name the terms the data uses. #1105 — a property with no declared domain was attached to every node shape. That states a constraint the ontology does not, and with minCount 1 it makes every instance of every class invalid. Such a property is now left unattached, with a warning naming it. Passing attach_domainless_properties=True restores the old behaviour. tests/ontology/test_shacl_target_namespace.py adds 17 tests that validate real data through pySHACL rather than reading the shapes text, so a shape that targets nothing cannot pass by being ignored. They cover a generated ontology, one that declares only a namespace, and one that carries only class URIs. tests/ontology/test_ontology_advanced.py::test_no_domain_property_attaches_to_all_shapes asserted the #1105 behaviour, so it pinned the defect in place. It is now two tests: the old expectation against the explicit opt-in, and the new default. Export and ontology suites pass at 239 tests. --- semantica/ontology/ontology_generator.py | 129 ++++++++++- tests/ontology/test_ontology_advanced.py | 33 ++- tests/ontology/test_shacl_target_namespace.py | 216 ++++++++++++++++++ 3 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 tests/ontology/test_shacl_target_namespace.py diff --git a/semantica/ontology/ontology_generator.py b/semantica/ontology/ontology_generator.py index 80851f63..564b94d7 100644 --- a/semantica/ontology/ontology_generator.py +++ b/semantica/ontology/ontology_generator.py @@ -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 @@ -751,6 +752,10 @@ class SHACLGraph: shapes_uri: str node_shapes: List[NodeShape] = field(default_factory=list) prefixes: Dict[str, str] = field(default_factory=dict) + # Bare class and property 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). + term_iris: Dict[str, str] = field(default_factory=dict) class SHACLGenerator: @@ -783,8 +788,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("/") + "/" @@ -792,6 +812,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 ──────────────────────────────────────────────────────────── @@ -829,10 +851,14 @@ 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, + term_iris=self._build_term_index(classes, properties, target_ns), ) self.progress_tracker.update_tracking(tracking_id, message="Generating node shapes") @@ -901,6 +927,83 @@ 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, + classes: List[Dict[str, Any]], + properties: List[Dict[str, Any]], + target_ns: str, + ) -> Dict[str, str]: + """Map every class and property name to the absolute IRI it expands to.""" + index: Dict[str, str] = {} + for term in list(classes or []) + list(properties 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]]: @@ -949,13 +1052,22 @@ class SHACLGenerator: self.logger.debug( f"Property '{pname}' domain '{d}' has no matching node shape — skipped" ) - else: - # No domain declared → attach to all shapes + elif self.attach_domainless_properties: self.logger.debug( - f"Property '{pname}' has no domain — attaching to all node shapes" + f"Property '{pname}' has no domain, attaching to all node shapes " + "because attach_domainless_properties is set" ) 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", "") @@ -1036,9 +1148,18 @@ class SHACLGenerator: 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.""" + """ + Return a URI reference for a class or property name. + + Bare names are expanded through the graph's term index, which holds the + IRI the data actually uses, rather than being pasted onto whichever + namespace the shapes happen to live in (#1104). + """ if local.startswith("http://") or local.startswith("https://"): return f"<{local}>" + resolved = graph.term_iris.get(local) + if resolved: + return f"<{resolved}>" if ":" in local: return local return f"ex:{local}" diff --git a/tests/ontology/test_ontology_advanced.py b/tests/ontology/test_ontology_advanced.py index 14ac52d5..98d5dc6e 100644 --- a/tests/ontology/test_ontology_advanced.py +++ b/tests/ontology/test_ontology_advanced.py @@ -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() diff --git a/tests/ontology/test_shacl_target_namespace.py b/tests/ontology/test_shacl_target_namespace.py new file mode 100644 index 00000000..74449490 --- /dev/null +++ b/tests/ontology/test_shacl_target_namespace.py @@ -0,0 +1,216 @@ +""" +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: . +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) From 66e3333e41772e10501c7711583beb288cb65176 Mon Sep 17 00:00:00 2001 From: FABIOTESS Date: Wed, 19 Aug 2026 17:37:07 +0100 Subject: [PATCH 2/2] fix(ontology): address review findings on the SHACL namespace fix Four findings from the automated review, all reproduced first. 1. The fix only reached Turtle. `_uri` was the single place I corrected, and JSON-LD and N-Triples build sh:targetClass, sh:path and sh:class straight from graph.base_uri, so two of the three formats went on emitting shapes that match nothing. That is the defect this PR claims to close, still live wherever the output is not Turtle. All three serializers now resolve through one `_term_iri`, and the pySHACL violation test runs against each of them. 2. Classes and properties shared one name-keyed index built with setdefault, so a property named after a class was permanently mapped to the class IRI and its sh:path validated the wrong predicate. The index is now split into class_iris and property_iris, and each call site says which it wants. 3. OntologyEngine.to_shacl forwarded target_namespace and attach_domainless_properties through generate(**options), which never reads them, so both were silently dropped on the public path. They are now named parameters passed to the constructor, and documented. 4. The opt-in attachment logged at debug. It broadens constraint generation, so it warns. 7 further tests, including the target-namespace and real-violation checks parametrised across Turtle, N-Triples and JSON-LD. --- semantica/ontology/engine.py | 12 ++ semantica/ontology/ontology_generator.py | 82 ++++++++----- tests/ontology/test_shacl_target_namespace.py | 114 ++++++++++++++++++ 3 files changed, 177 insertions(+), 31 deletions(-) diff --git a/semantica/ontology/engine.py b/semantica/ontology/engine.py index d698f5e2..8cf7b1d4 100644 --- a/semantica/ontology/engine.py +++ b/semantica/ontology/engine.py @@ -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") diff --git a/semantica/ontology/ontology_generator.py b/semantica/ontology/ontology_generator.py index 564b94d7..0a29c2fb 100644 --- a/semantica/ontology/ontology_generator.py +++ b/semantica/ontology/ontology_generator.py @@ -752,10 +752,13 @@ class SHACLGraph: shapes_uri: str node_shapes: List[NodeShape] = field(default_factory=list) prefixes: Dict[str, str] = field(default_factory=dict) - # Bare class and property 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). - term_iris: 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: @@ -858,7 +861,8 @@ class SHACLGenerator: base_uri=base_uri, shapes_uri=self.shapes_uri, prefixes=prefixes, - term_iris=self._build_term_index(classes, properties, target_ns), + 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") @@ -979,14 +983,11 @@ class SHACLGenerator: return self._DEFAULT_TARGET_NAMESPACE def _build_term_index( - self, - classes: List[Dict[str, Any]], - properties: List[Dict[str, Any]], - target_ns: str, + self, terms: List[Dict[str, Any]], target_ns: str ) -> Dict[str, str]: - """Map every class and property name to the absolute IRI it expands to.""" + """Map each term's name to the absolute IRI it expands to.""" index: Dict[str, str] = {} - for term in list(classes or []) + list(properties or []): + for term in terms or []: if not isinstance(term, dict): continue name = term.get("name") @@ -1053,9 +1054,10 @@ class SHACLGenerator: f"Property '{pname}' domain '{d}' has no matching node shape — skipped" ) elif self.attach_domainless_properties: - self.logger.debug( - f"Property '{pname}' has no domain, attaching to all node shapes " - "because attach_domainless_properties is set" + 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)) @@ -1147,22 +1149,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: + def _term_iri(self, graph: SHACLGraph, local: str, kind: str = "class") -> str: """ - Return a URI reference for a class or property name. + Resolve a class or property name to the absolute IRI the data uses. - Bare names are expanded through the graph's term index, which holds the - IRI the data actually uses, rather than being pasted onto whichever - namespace the shapes happen to live in (#1104). + 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}>" - resolved = graph.term_iris.get(local) + return local + index = graph.property_iris if kind == "property" else graph.class_iris + resolved = index.get(local) if resolved: - return f"<{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), ""] @@ -1189,11 +1209,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: @@ -1234,7 +1254,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 @@ -1247,7 +1267,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( @@ -1255,7 +1275,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: @@ -1288,7 +1308,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: @@ -1303,13 +1323,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: diff --git a/tests/ontology/test_shacl_target_namespace.py b/tests/ontology/test_shacl_target_namespace.py index 74449490..5799e86a 100644 --- a/tests/ontology/test_shacl_target_namespace.py +++ b/tests/ontology/test_shacl_target_namespace.py @@ -214,3 +214,117 @@ def test_domainless_attachment_is_available_as_an_explicit_opt_in(): 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