Skip to content

Commit a710ab0

Browse files
authored
Merge pull request crs4#170 from kikkomep/fix/shacl-issues
fix(shacl): correct property subgraph and zero-shape profile evaluation
2 parents 9bc3063 + 4c2fbbd commit a710ab0

7 files changed

Lines changed: 1142 additions & 389 deletions

File tree

rocrate_validator/models.py

Lines changed: 621 additions & 336 deletions
Large diffs are not rendered by default.

rocrate_validator/requirements/shacl/requirements.py

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,14 @@
1919

2020
from rocrate_validator.utils import log as logging
2121
from rocrate_validator.constants import VALIDATOR_NS
22-
from rocrate_validator.models import (Profile, Requirement, RequirementCheck,
23-
RequirementLevel, RequirementLoader)
22+
from rocrate_validator.models import (
23+
Profile,
24+
Requirement,
25+
RequirementCheck,
26+
RequirementLevel,
27+
RequirementLoader,
28+
ValidationContext,
29+
)
2430
from rocrate_validator.requirements.shacl.checks import SHACLCheck
2531
from rocrate_validator.requirements.shacl.models import Shape, ShapesRegistry
2632

@@ -29,16 +35,11 @@
2935

3036

3137
class SHACLRequirement(Requirement):
32-
33-
def __init__(self,
34-
shape: Shape,
35-
profile: Profile,
36-
path: Path):
38+
def __init__(self, shape: Shape, profile: Profile, path: Path):
3739
self._shape = shape
38-
super().__init__(profile,
39-
shape.name if shape.name else "",
40-
shape.description if shape.description else "",
41-
path)
40+
super().__init__(
41+
profile, shape.name if shape.name else "", shape.description if shape.description else "", path
42+
)
4243
# init checks
4344
self._checks = self.__init_checks__()
4445
# assign check IDs
@@ -59,8 +60,15 @@ def __init_checks__(self) -> list[RequirementCheck]:
5960
# check if the shape has nested properties
6061
has_properties = hasattr(self.shape, "properties") and len(self.shape.properties) > 0
6162
# create a check for the shape itself, hidden if the shape has nested properties
62-
checks.append(SHACLCheck(self, self.shape, name=f"Check {self.shape.name}" if has_properties else None,
63-
hidden=has_properties, root=True))
63+
checks.append(
64+
SHACLCheck(
65+
self,
66+
self.shape,
67+
name=f"Check {self.shape.name}" if has_properties else None,
68+
hidden=has_properties,
69+
root=True,
70+
)
71+
)
6472
# create a check for each property if the shape has nested properties
6573
if has_properties:
6674
for prop in self.shape.properties:
@@ -82,9 +90,67 @@ def hidden(self) -> bool:
8290
return True
8391
return False
8492

93+
@classmethod
94+
def finalize(cls, context: ValidationContext) -> None:
95+
""" "
96+
Finalize the SHACL requirement by ensuring that a SHACL validation run is triggered for the target profile
97+
if it has no shapes of its own (e.g. an extension profile that purely inherits or only deactivates).
98+
99+
SHACL is normally driven by the first execute_check of a check
100+
belonging to the target profile (see SHACLValidationContextManager).
101+
If the target has zero SHACL checks of its own (e.g. an extension
102+
profile that purely inherits or only deactivates), no pyshacl run
103+
is ever triggered and inherited shapes are never evaluated.
104+
Force one final run on the merged shapes graph in that case.
105+
"""
106+
107+
logger.debug("Starting %s requirement finalization for context %s", cls.__name__, context)
108+
109+
# extract profiles and target profile from context
110+
profiles = context.profiles
111+
112+
from rocrate_validator.requirements.shacl.checks import SHACLCheck
113+
from rocrate_validator.requirements.shacl.validator import SHACLValidationContext
114+
115+
target = next((p for p in profiles if p.identifier == context.settings.profile_identifier), None)
116+
if target is None:
117+
return
118+
119+
shacl_context = SHACLValidationContext.get_instance(context)
120+
# If pyshacl already ran for the target during the main loop there is
121+
# nothing to do.
122+
if shacl_context.get_validation_result(target) is not None:
123+
return
124+
125+
# Pick any SHACLCheck across the loaded profiles to drive the run; the
126+
# check identity is only used for logging inside __do_execute_check__,
127+
# the actual validation is graph-wide.
128+
runner = next(
129+
(c for p in profiles for r in p.requirements for c in r.get_checks() if isinstance(c, SHACLCheck)),
130+
None,
131+
)
132+
if runner is None:
133+
return
134+
135+
# Make sure the target's shapes (if any) are in the merged registry
136+
# and switch the current profile so violations are attributed under
137+
# the target profile in the report.
138+
shacl_context.__set_current_validation_profile__(target)
139+
shacl_context._current_validation_profile = target
140+
try:
141+
runner.__do_execute_check__(shacl_context)
142+
except Exception as e:
143+
logger.warning("Forced SHACL run for zero-shape target profile %s failed: %s", target.identifier, e)
144+
if logger.isEnabledFor(logging.DEBUG):
145+
logger.exception(e)
146+
finally:
147+
shacl_context.__unset_current_validation_profile__()
148+
149+
# do finalization logic here (empty for now)
150+
logger.debug("Completed %s requirement finalization for context %s", cls.__name__, context)
85151

86-
class SHACLRequirementLoader(RequirementLoader):
87152

153+
class SHACLRequirementLoader(RequirementLoader):
88154
def __init__(self, profile: Profile):
89155
super().__init__(profile)
90156
self._shape_registry = ShapesRegistry.get_instance(profile)
@@ -95,9 +161,9 @@ def __init__(self, profile: Profile):
95161
def shapes_registry(self) -> ShapesRegistry:
96162
return self._shape_registry
97163

98-
def load(self, profile: Profile,
99-
requirement_level: RequirementLevel,
100-
file_path: Path, publicID: Optional[str] = None) -> list[Requirement]:
164+
def load(
165+
self, profile: Profile, requirement_level: RequirementLevel, file_path: Path, publicID: Optional[str] = None
166+
) -> list[Requirement]:
101167
assert file_path is not None, "The file path cannot be None"
102168
shapes: list[Shape] = self.shapes_registry.load_shapes(file_path, publicID)
103169
logger.debug("Loaded %s shapes: %s", len(shapes), shapes)

rocrate_validator/requirements/shacl/utils.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -190,20 +190,23 @@ def get_shape_graph(self, shape_node: Node) -> Graph:
190190

191191
def get_shape_property_graph(self, shape_node: Node, shape_property: Node) -> Graph:
192192
"""
193-
Get the subgraph of the given shape node excluding the given property
193+
Get the subgraph of a property shape nested inside a node shape.
194+
195+
Includes only triples reachable from `shape_property` (its constraints
196+
and any RDF lists used by `sh:and`/`sh:or`/`sh:xone`), plus the link
197+
triple `(shape_node, sh:property, shape_property)`. Nothing reachable
198+
only via sibling properties is included, so subtracting this graph
199+
from the merged shapes graph cannot break sibling constructs.
194200
"""
195201
node_graph = self.get_shape_graph(shape_node)
196202
assert node_graph is not None, "The shape graph cannot be None"
197203

198204
property_graph = Graph()
199-
shacl_ns = Namespace(SHACL_NS)
200-
nested_properties_to_exclude = [o for (_, _, o) in node_graph.triples(
201-
(shape_node, shacl_ns.property, None)) if o != shape_property]
202-
triples_to_exclude = [(s, _, o) for (s, _, o) in node_graph.triples((None, None, None))
203-
if s in nested_properties_to_exclude
204-
or o in nested_properties_to_exclude]
205+
for s, p, o in __extract_related_triples__(node_graph, shape_property):
206+
property_graph.add((s, p, o))
205207

206-
property_graph += node_graph - triples_to_exclude
208+
shacl_ns = Namespace(SHACL_NS)
209+
property_graph.add((shape_node, shacl_ns.property, shape_property))
207210

208211
return property_graph
209212

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright (c) 2024-2026 CRS4
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
@prefix dct: <http://purl.org/dc/terms/> .
16+
@prefix prof: <http://www.w3.org/ns/dx/prof/> .
17+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
18+
19+
# A "wrapper" extension profile that inherits from c without declaring any
20+
# SHACL shape of its own. Used to exercise the zero-shape target profile
21+
# path: pyshacl must still run for inherited shapes to be evaluated.
22+
<https://w3id.org/c-wrapper>
23+
a prof:Profile ;
24+
rdfs:label "Profile C-wrapper" ;
25+
rdfs:comment """Pure inheritance from Profile C, no own checks."""@en ;
26+
dct:publisher <https://publisherCwrapper> ;
27+
prof:isProfileOf <https://w3id.org/c> ;
28+
prof:isTransitiveProfileOf <https://w3id.org/a>, <https://w3id.org/c> ;
29+
prof:hasToken "c-wrapper" ;
30+
.

tests/unit/requirements/test_profiles.py

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@
1818
import pytest
1919

2020
from rocrate_validator.constants import DEFAULT_PROFILE_IDENTIFIER
21-
from rocrate_validator.errors import (DuplicateRequirementCheck,
22-
InvalidProfilePath,
23-
ProfileSpecificationError)
24-
from rocrate_validator.models import (Profile, ValidationContext,
25-
ValidationSettings, Validator)
21+
from rocrate_validator.errors import DuplicateRequirementCheck, InvalidProfilePath, ProfileSpecificationError
22+
from rocrate_validator.models import Profile, ValidationContext, ValidationSettings, Validator
23+
from rocrate_validator.requirements.shacl.checks import SHACLCheck
2624
from tests.ro_crates import InvalidFileDescriptorEntity, ValidROC
2725

2826
# set up logging
@@ -57,7 +55,7 @@ def test_load_invalid_profile_from_validation_context(fake_profiles_path: str):
5755
"profiles_path": "/tmp/random_path_xxx",
5856
"profile_identifier": DEFAULT_PROFILE_IDENTIFIER,
5957
"rocrate_uri": ValidROC().wrroc_paper,
60-
"enable_profile_inheritance": False
58+
"enable_profile_inheritance": False,
6159
}
6260

6361
settings = ValidationSettings(**settings)
@@ -79,7 +77,7 @@ def test_load_valid_profile_without_inheritance_from_validation_context(fake_pro
7977
"profiles_path": fake_profiles_path,
8078
"profile_identifier": "c",
8179
"rocrate_uri": ValidROC().wrroc_paper,
82-
"enable_profile_inheritance": False
80+
"enable_profile_inheritance": False,
8381
}
8482

8583
settings = ValidationSettings(**settings)
@@ -128,7 +126,8 @@ def test_profile_spec_properties(fake_profiles_path: str):
128126
assert profile.version == "1.0.0", "The profile version should be 1.0.0"
129127
assert profile.is_profile_of == ["https://w3id.org/a"], "The profileOf property should be ['a']"
130128
assert profile.is_transitive_profile_of == [
131-
"https://w3id.org/a"], "The transitiveProfileOf property should be ['a']"
129+
"https://w3id.org/a"
130+
], "The transitiveProfileOf property should be ['a']"
132131

133132

134133
def test_profiles_loading_free_folder_structure(profiles_with_free_folder_structure_path: str):
@@ -206,8 +205,9 @@ def __perform_test__(profile_identifier: str, expected_inherited_profiles: list[
206205

207206
# The number of profiles should be 1
208207
profiles_names = [_.token for _ in profile.inherited_profiles]
209-
assert profiles_names == expected_inherited_profiles, \
210-
f"The number of profiles should be {expected_inherited_profiles}"
208+
assert (
209+
profiles_names == expected_inherited_profiles
210+
), f"The number of profiles should be {expected_inherited_profiles}"
211211

212212
# Test the inheritance mode with 1 profile
213213
__perform_test__("a", [])
@@ -252,7 +252,7 @@ def test_load_invalid_profile_with_override_on_same_profile(fake_profiles_path:
252252
"profile_identifier": "invalid-duplicated-shapes",
253253
"rocrate_uri": ValidROC().wrroc_paper,
254254
"enable_profile_inheritance": True,
255-
"allow_requirement_check_override": False
255+
"allow_requirement_check_override": False,
256256
}
257257

258258
settings = ValidationSettings(**settings)
@@ -275,7 +275,7 @@ def test_load_valid_profile_with_override_on_inherited_profile(fake_profiles_pat
275275
"profile_identifier": "c-overridden",
276276
"rocrate_uri": ValidROC().wrroc_paper,
277277
"enable_profile_inheritance": True,
278-
"allow_requirement_check_override": True
278+
"allow_requirement_check_override": True,
279279
}
280280

281281
settings = ValidationSettings(**settings)
@@ -297,6 +297,34 @@ def test_load_valid_profile_with_override_on_inherited_profile(fake_profiles_pat
297297
assert len(requirements_checks) == 3, "The number of requirements should be 2"
298298

299299

300+
def test_zero_shape_target_profile_triggers_pyshacl_run(fake_profiles_path: str):
301+
"""Regression test for the 0-shape profile bug:
302+
when the target profile has no SHACL checks of its own,
303+
Validator must still drive a single pyshacl run
304+
on the merged shapes graph so inherited shapes get evaluated.
305+
Without the fix in `Validator.__ensure_target_shacl_run__`,
306+
no SHACLCheck would be recorded as executed for the wrapper target."""
307+
308+
settings = ValidationSettings(
309+
**{
310+
"profiles_path": fake_profiles_path,
311+
"profile_identifier": "c-wrapper",
312+
"rocrate_uri": ValidROC().wrroc_paper,
313+
"enable_profile_inheritance": True,
314+
"allow_requirement_check_override": True,
315+
"disable_check_for_duplicates": True,
316+
}
317+
)
318+
result = Validator(settings).validate()
319+
320+
executed_shacl = [c for c in result.executed_checks if isinstance(c, SHACLCheck)]
321+
assert executed_shacl, (
322+
"Expected at least one inherited SHACLCheck to be executed for the "
323+
"c-wrapper target. None recorded — the zero-shape pyshacl run was "
324+
"skipped."
325+
)
326+
327+
300328
def test_profile_parents(check_overriding_profiles_path: str):
301329
"""Test the order of the loaded profiles."""
302330
logger.debug("The profiles path: %r", check_overriding_profiles_path)
@@ -366,29 +394,31 @@ def test_profile_check_overriding(check_overriding_profiles_path: str):
366394

367395
def check_profile(profile, check, inherited_profiles, overridden_by, override):
368396
# Check inherited profiles
369-
assert len(profile.inherited_profiles) == len(inherited_profiles), \
370-
f"The number of inherited profiles should be {len(inherited_profiles)}"
397+
assert len(profile.inherited_profiles) == len(
398+
inherited_profiles
399+
), f"The number of inherited profiles should be {len(inherited_profiles)}"
371400
inherited_profiles_tokens = [_.token for _ in profile.inherited_profiles]
372-
assert set(inherited_profiles_tokens) == set(inherited_profiles), \
373-
f"The inherited profiles should be {inherited_profiles}"
401+
assert set(inherited_profiles_tokens) == set(
402+
inherited_profiles
403+
), f"The inherited profiles should be {inherited_profiles}"
374404

375405
# Check overridden status
376-
logger.debug("%r overridden by: %r", check.identifier, [
377-
_.requirement.profile.identifier for _ in check.overridden_by])
378-
assert check.overridden == (len(overridden_by) > 0), \
379-
f"The check overridden status should be {len(overridden_by) > 0}"
380-
assert len(check.overridden_by) == len(overridden_by), \
381-
f"The number of overridden checks should be {len(overridden_by)}"
406+
logger.debug(
407+
"%r overridden by: %r", check.identifier, [_.requirement.profile.identifier for _ in check.overridden_by]
408+
)
409+
assert check.overridden == (
410+
len(overridden_by) > 0
411+
), f"The check overridden status should be {len(overridden_by) > 0}"
412+
assert len(check.overridden_by) == len(
413+
overridden_by
414+
), f"The number of overridden checks should be {len(overridden_by)}"
382415
overridden_by_tokens = [_.requirement.profile.identifier for _ in check.overridden_by]
383-
assert set(overridden_by_tokens) == set(overridden_by), \
384-
f"The overridden checks should be {overridden_by}"
416+
assert set(overridden_by_tokens) == set(overridden_by), f"The overridden checks should be {overridden_by}"
385417

386418
# Check override status
387-
assert len(check.overrides) == len(override), \
388-
f"The number of overridden checks should be {len(override)}"
419+
assert len(check.overrides) == len(override), f"The number of overridden checks should be {len(override)}"
389420
override_tokens = [_.requirement.profile.identifier for _ in check.overrides]
390-
assert set(override_tokens) == set(override), \
391-
f"The overridden checks should be {override}"
421+
assert set(override_tokens) == set(override), f"The overridden checks should be {override}"
392422

393423
# Check the number of requirements and checks of each profile
394424
for profile in profiles:

0 commit comments

Comments
 (0)