Skip to content

Commit b3bfa0b

Browse files
authored
Merge pull request crs4#168 from kikkomep/fix/requirement-severity
fix(shacl): correct severity resolution for flat shapes
2 parents 9a3047a + 254fb88 commit b3bfa0b

3 files changed

Lines changed: 138 additions & 8 deletions

File tree

rocrate_validator/models.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2449,17 +2449,21 @@ def add_issue(
24492449
@property
24502450
def failed_requirements(self) -> Collection[Requirement]:
24512451
"""
2452-
Get the requirements that failed
2452+
Get the requirements that failed at or above the configured `requirement_severity`.
24532453
"""
2454-
return set(issue.check.requirement for issue in self._issues)
2454+
min_severity = self.context.requirement_severity
2455+
return set(issue.check.requirement for issue in self._issues
2456+
if issue.severity >= min_severity)
24552457

24562458
# --- Checks ---
24572459
@property
24582460
def failed_checks(self) -> Collection[RequirementCheck]:
24592461
"""
2460-
Get the checks that failed
2462+
Get the checks that failed at or above the configured `requirement_severity`.
24612463
"""
2462-
return set(issue.check for issue in self._issues)
2464+
min_severity = self.context.requirement_severity
2465+
return set(issue.check for issue in self._issues
2466+
if issue.severity >= min_severity)
24632467

24642468
def get_failed_checks_by_requirement(self, requirement: Requirement) -> Collection[RequirementCheck]:
24652469
"""

rocrate_validator/requirements/shacl/checks.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Requirement,
2424
RequirementCheck,
2525
RequirementCheckValidationEvent,
26+
RequirementLevel,
2627
SkipRequirementCheck,
2728
ValidationContext,
2829
)
@@ -105,13 +106,28 @@ def description(self) -> str:
105106
return self._shape.parent.description
106107
return f"Check for {self._shape.name}" if self._shape.name else "SHACL validation check"
107108

108-
def __compute_requirement_level__(self) -> LevelCollection:
109+
def __compute_requirement_level__(self) -> RequirementLevel:
109110
if self._shape and self._shape.get_declared_level():
110111
return self._shape.get_declared_level()
111112
if self.requirement and self.requirement.requirement_level_from_path:
112113
return self.requirement.requirement_level_from_path
114+
# When the shape file lives in the profile root and the NodeShape
115+
# itself does not declare sh:severity, derive the level from the
116+
# most severe nested PropertyShape instead of defaulting to REQUIRED.
117+
derived = self.__derive_level_from_properties__()
118+
if derived:
119+
return derived
113120
return LevelCollection.REQUIRED
114121

122+
def __derive_level_from_properties__(self) -> Optional[RequirementLevel]:
123+
properties = getattr(self._shape, "properties", None)
124+
if not properties:
125+
return None
126+
declared_levels = [lvl for lvl in (p.get_declared_level() for p in properties) if lvl]
127+
if not declared_levels:
128+
return None
129+
return max(declared_levels, key=lambda lvl: lvl.severity.value)
130+
115131
@property
116132
def level(self) -> str:
117133
if not self._level:
@@ -266,6 +282,18 @@ def __do_execute_check__(self, shacl_context: SHACLValidationContext):
266282
if requirementCheck is None:
267283
logger.warning("No check instance found for shape: %s", shape.key)
268284
continue
285+
# Drop violations whose check severity is below the requested
286+
# `requirement_severity`: pyshacl still emits sh:ValidationResult
287+
# nodes for sh:Warning / sh:Info, but they are not actionable at a
288+
# stricter validation level.
289+
if requirementCheck.severity < shacl_context.settings.requirement_severity:
290+
logger.debug(
291+
"Dropping violation for check %s: severity %s below requested %s",
292+
requirementCheck.identifier,
293+
requirementCheck.severity,
294+
shacl_context.settings.requirement_severity,
295+
)
296+
continue
269297
if (
270298
not shacl_context.settings.skip_checks
271299
or requirementCheck.identifier not in shacl_context.settings.skip_checks

tests/unit/requirements/test_shacl_checks.py

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,20 @@
1717
from rdflib import BNode, Graph, Namespace, URIRef
1818

1919
from rocrate_validator.constants import SHACL_NS
20+
from rocrate_validator.models import LevelCollection
2021
from rocrate_validator.requirements.shacl.checks import SHACLCheck
21-
from rocrate_validator.requirements.shacl.models import Shape, ShapesRegistry
22+
from rocrate_validator.requirements.shacl.models import (NodeShape,
23+
PropertyShape, Shape,
24+
ShapesRegistry)
2225
from rocrate_validator.requirements.shacl.utils import resolve_parent_shape
2326

2427
logger = logging.getLogger(__name__)
2528

2629

2730
class MockRequirement:
28-
def __init__(self):
31+
def __init__(self, requirement_level_from_path=None):
2932
self.profile = None
30-
self.requirement_level_from_path = None
33+
self.requirement_level_from_path = requirement_level_from_path
3134

3235

3336
class MockParentShape:
@@ -220,3 +223,98 @@ def test_resolve_parent_shape_with_property_bnode():
220223

221224
assert result is not None, "Should resolve parent shape for property BNode"
222225
assert result.key == shape.key
226+
227+
228+
def _make_property(graph: Graph, severity_term: str = None) -> PropertyShape:
229+
"""Build a PropertyShape on a fresh BNode, optionally setting sh:severity."""
230+
prop = PropertyShape(BNode(), graph)
231+
if severity_term is not None:
232+
prop.severity = severity_term
233+
return prop
234+
235+
236+
def test_derive_level_picks_most_stringent_declared_property_severity():
237+
"""
238+
Flat NodeShape with no declared severity inherits the highest severity
239+
declared by its nested properties.
240+
"""
241+
g = Graph()
242+
shape = NodeShape(URIRef("http://example.org/NodeShape"), g)
243+
shape.add_property(_make_property(g, f"{SHACL_NS}Info"))
244+
shape.add_property(_make_property(g, f"{SHACL_NS}Warning"))
245+
shape.add_property(_make_property(g, f"{SHACL_NS}Info"))
246+
247+
check = SHACLCheck(MockRequirement(), shape)
248+
249+
assert check.level == LevelCollection.RECOMMENDED
250+
251+
252+
def test_derive_level_with_uniform_property_severity():
253+
"""When every property declares the same severity, derive that severity."""
254+
g = Graph()
255+
shape = NodeShape(URIRef("http://example.org/NodeShape"), g)
256+
shape.add_property(_make_property(g, f"{SHACL_NS}Info"))
257+
shape.add_property(_make_property(g, f"{SHACL_NS}Info"))
258+
259+
check = SHACLCheck(MockRequirement(), shape)
260+
261+
assert check.level == LevelCollection.OPTIONAL
262+
263+
264+
def test_derive_level_ignores_properties_without_declared_severity():
265+
"""Properties without sh:severity are skipped; only declared ones drive the result."""
266+
g = Graph()
267+
shape = NodeShape(URIRef("http://example.org/NodeShape"), g)
268+
shape.add_property(_make_property(g)) # no severity declared
269+
shape.add_property(_make_property(g, f"{SHACL_NS}Warning"))
270+
271+
check = SHACLCheck(MockRequirement(), shape)
272+
273+
assert check.level == LevelCollection.RECOMMENDED
274+
275+
276+
def test_derive_level_falls_back_to_required_when_no_property_declares_severity():
277+
"""If no nested property declares a severity, fall back to REQUIRED."""
278+
g = Graph()
279+
shape = NodeShape(URIRef("http://example.org/NodeShape"), g)
280+
shape.add_property(_make_property(g))
281+
shape.add_property(_make_property(g))
282+
283+
check = SHACLCheck(MockRequirement(), shape)
284+
285+
assert check.level == LevelCollection.REQUIRED
286+
287+
288+
def test_shape_declared_severity_takes_precedence_over_derivation():
289+
"""An explicit severity on the NodeShape wins over property-based derivation."""
290+
g = Graph()
291+
shape = NodeShape(URIRef("http://example.org/NodeShape"), g)
292+
shape.severity = f"{SHACL_NS}Warning"
293+
shape.add_property(_make_property(g, f"{SHACL_NS}Violation"))
294+
295+
check = SHACLCheck(MockRequirement(), shape)
296+
297+
assert check.level == LevelCollection.RECOMMENDED
298+
299+
300+
def test_path_based_level_takes_precedence_over_derivation():
301+
"""When the requirement file is in a must/should/may folder the path level wins."""
302+
g = Graph()
303+
shape = NodeShape(URIRef("http://example.org/NodeShape"), g)
304+
shape.add_property(_make_property(g, f"{SHACL_NS}Info"))
305+
306+
check = SHACLCheck(
307+
MockRequirement(requirement_level_from_path=LevelCollection.SHOULD), shape
308+
)
309+
310+
assert check.level == LevelCollection.SHOULD
311+
312+
313+
def test_derive_level_for_node_shape_without_properties():
314+
"""A NodeShape with no nested properties falls back to REQUIRED."""
315+
g = Graph()
316+
shape = NodeShape(URIRef("http://example.org/NodeShape"), g)
317+
318+
check = SHACLCheck(MockRequirement(), shape)
319+
320+
assert check.level == LevelCollection.REQUIRED

0 commit comments

Comments
 (0)