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 819f1b94..253fc852 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 @@ -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: 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..5799e86a --- /dev/null +++ b/tests/ontology/test_shacl_target_namespace.py @@ -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: . +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