Merge pull request #1094 from cxzg007/fix/shacl-explain-violations-real-constraint-values

fix(ontology): render real SHACL constraint values in explain_violations
This commit is contained in:
Mohd Kaif
2026-08-19 15:54:45 +05:30
committed by GitHub
2 changed files with 141 additions and 4 deletions
+44 -4
View File
@@ -33,6 +33,12 @@ class SHACLViolation:
value: Optional[str] = None
shape: Optional[str] = None
explanation: Optional[str] = None
# Real constraint parameters extracted from the source shape (sh:sourceShape),
# used to render accurate plain-English explanations.
min_count: Optional[int] = None
max_count: Optional[int] = None
datatype: Optional[str] = None
class_: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
@@ -44,6 +50,10 @@ class SHACLViolation:
"value": self.value,
"shape": self.shape,
"explanation": self.explanation,
"min_count": self.min_count,
"max_count": self.max_count,
"datatype": self.datatype,
"class_": self.class_,
}
@@ -118,10 +128,10 @@ class SHACLValidationReport:
focus_node=v.focus_node,
path=v.result_path or "",
value=v.value or "",
min_count=1,
max_count=1,
datatype=v.message or "",
class_=v.message or "",
min_count=v.min_count if v.min_count is not None else "?",
max_count=v.max_count if v.max_count is not None else "?",
datatype=v.datatype or "the expected datatype",
class_=v.class_ or "the required class",
)
def to_dict(self) -> Dict[str, Any]:
@@ -208,6 +218,32 @@ def _run_pyshacl(
shape_node = results_graph.value(result, SH.sourceShape)
shape = str(shape_node) if shape_node is not None else None
# Look up the real constraint parameters from the source shape so that
# explain_violations can render accurate values instead of placeholders.
# Note: sh:qualifiedMinCount / sh:qualifiedMaxCount are not handled here;
# such violations fall back to the "?" placeholder in explain_violations.
min_count: Optional[int] = None
max_count: Optional[int] = None
datatype: Optional[str] = None
class_: Optional[str] = None
if shape_node is not None:
min_node = shacl_g.value(shape_node, SH.minCount)
if min_node is not None:
try:
min_count = int(str(min_node))
except (TypeError, ValueError):
min_count = None
max_node = shacl_g.value(shape_node, SH.maxCount)
if max_node is not None:
try:
max_count = int(str(max_node))
except (TypeError, ValueError):
max_count = None
dt_node = shacl_g.value(shape_node, SH.datatype)
datatype = str(dt_node) if dt_node is not None else None
cls_node = shacl_g.value(shape_node, SH["class"])
class_ = str(cls_node) if cls_node is not None else None
v = SHACLViolation(
focus_node=focus,
result_path=path,
@@ -216,6 +252,10 @@ def _run_pyshacl(
message=msg,
value=val,
shape=shape,
min_count=min_count,
max_count=max_count,
datatype=datatype,
class_=class_,
)
if sev_str == "Violation":
violations.append(v)
+97
View File
@@ -414,6 +414,103 @@ class TestSHACLHierarchicalAndValidation(unittest.TestCase):
self.assertIsNotNone(v.explanation)
self.assertIn("https://example.com/john", v.explanation)
# 32b
def test_explain_violations_uses_real_constraint_values(self):
"""explain_violations must render the real min/max/datatype/class values,
not hardcoded placeholders (regression for PR #318)."""
from semantica.ontology.ontology_validator import (
SHACLValidationReport,
SHACLViolation,
)
max_v = SHACLViolation(
focus_node="https://example.com/john",
result_path="ex:age",
constraint="MaxCountConstraintComponent",
max_count=3,
)
dt_v = SHACLViolation(
focus_node="https://example.com/john",
result_path="ex:age",
constraint="DatatypeConstraintComponent",
value="abc",
datatype="http://www.w3.org/2001/XMLSchema#integer",
)
cls_v = SHACLViolation(
focus_node="https://example.com/john",
result_path="ex:knows",
constraint="ClassConstraintComponent",
value="https://example.com/thing",
class_="https://example.com/Person",
)
report = SHACLValidationReport(
conforms=False, violations=[max_v, dt_v, cls_v]
)
report.explain_violations()
# MaxCount must show the real limit (3), not the hardcoded 1.
self.assertIn("3", max_v.explanation)
self.assertNotIn("At most 1 value", max_v.explanation)
# Datatype must show the real datatype IRI, not the message.
self.assertIn(
"http://www.w3.org/2001/XMLSchema#integer", dt_v.explanation
)
# Class must show the real class IRI.
self.assertIn("https://example.com/Person", cls_v.explanation)
# 32c
def test_run_pyshacl_extracts_constraint_values_from_shape(self):
"""_run_pyshacl must back-reference sh:sourceShape to populate the real
constraint parameters on each violation."""
try:
import pyshacl # noqa: F401
import rdflib # noqa: F401
except ImportError:
self.skipTest("pyshacl/rdflib not installed")
from semantica.ontology.ontology_validator import _run_pyshacl
shacl = """
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.com/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
ex:PersonShape a sh:NodeShape ;
sh:targetClass ex:Person ;
sh:property [
sh:path ex:age ;
sh:datatype xsd:integer ;
sh:maxCount 2 ;
] .
"""
data = """
@prefix ex: <http://example.com/> .
ex:john a ex:Person ;
ex:age "not-a-number" ;
ex:age 1 ;
ex:age 2 ;
ex:age 3 .
"""
report = _run_pyshacl(data, shacl)
self.assertFalse(report.conforms)
# Datatype violation should carry the real xsd:integer datatype.
dt = [
v
for v in report.violations
if v.constraint == "DatatypeConstraintComponent"
]
self.assertTrue(dt)
self.assertTrue(
dt[0].datatype.endswith("integer"),
f"expected integer datatype, got {dt[0].datatype}",
)
# MaxCount violation should carry the real max_count == 2.
mc = [
v
for v in report.violations
if v.constraint == "MaxCountConstraintComponent"
]
if mc:
self.assertEqual(mc[0].max_count, 2)
# 33
def test_shacl_violation_to_dict(self):
from semantica.ontology.ontology_validator import SHACLViolation