Skip to content

Commit fd39545

Browse files
authored
Merge pull request crs4#172 from kikkomep/feat/disabling-check
feat(checks): allow extension profiles to deactivate inherited checks
2 parents b3bfa0b + 0b6bff7 commit fd39545

10 files changed

Lines changed: 548 additions & 44 deletions

File tree

docs/11_writing_a_profile.rst

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,154 @@ These instructions assume you are familiar with code development using Python an
9494
#. When your profile & tests are written, open a pull request to contribute
9595
it back to the repository!
9696

97+
Overriding inherited checks
98+
---------------------------
99+
100+
When a profile inherits from another profile (via ``prof:isProfileOf`` /
101+
``prof:isTransitiveProfileOf``), it automatically receives every check
102+
declared by its ancestors. The validator additionally supports
103+
**override-by-name**: a child profile can replace an inherited check by
104+
declaring a new check with the **same name**.
105+
106+
This allows an extension profile to *redefine* the content of an inherited
107+
check — for example, to make a constraint stricter or looser, change its
108+
severity, or, as described in the next section, fully deactivate it.
109+
110+
Override-by-name is enabled by default. It can be disabled via the
111+
``allow_requirement_check_override`` validation setting (CLI / API), which
112+
will raise an error on duplicate check names instead.
113+
114+
SHACL checks
115+
^^^^^^^^^^^^
116+
117+
Each SHACL ``NodeShape`` / ``PropertyShape`` becomes a check whose name is
118+
its ``sh:name``. To override an inherited check, declare a shape in the
119+
extension profile with the **same** ``sh:name`` as the inherited one:
120+
121+
.. code-block:: turtle
122+
123+
# Parent profile
124+
ro:ShapeC
125+
a sh:NodeShape ;
126+
sh:name "The Shape C" ;
127+
sh:targetNode ro:ro-crate-metadata.json ;
128+
sh:property [
129+
a sh:PropertyShape ;
130+
sh:name "Check Metadata File Descriptor entity existence" ;
131+
sh:path rdf:type ;
132+
sh:minCount 1 ;
133+
sh:message "Missing entity" ;
134+
] .
135+
136+
.. code-block:: turtle
137+
138+
# Extension profile — overrides the inherited PropertyShape by sh:name
139+
ro:ShapeC
140+
a sh:NodeShape ;
141+
sh:name "The Shape C" ;
142+
sh:targetNode ro:ro-crate-metadata.json ;
143+
sh:property [
144+
a sh:PropertyShape ;
145+
sh:name "Check Metadata File Descriptor entity existence" ;
146+
sh:path rdf:type ;
147+
sh:minCount 1 ;
148+
sh:maxCount 1 ;
149+
sh:message "Stricter override from extension profile" ;
150+
] .
151+
152+
Both top-level shapes and ``PropertyShape`` entries nested inside a parent
153+
``NodeShape`` (i.e., declared inline, without an absolute IRI) can be
154+
overridden this way.
155+
156+
Python checks
157+
^^^^^^^^^^^^^
158+
159+
Python checks declared via the ``@check`` decorator are matched by their
160+
``name`` argument. To override an inherited Python check, declare a new
161+
function with the same ``name`` in the extension profile:
162+
163+
.. code-block:: python
164+
165+
# In the extension profile's checks module
166+
from rocrate_validator.requirements.python import check
167+
168+
@check(name="Check Metadata File Descriptor entity existence")
169+
def overridden_check(self, ctx):
170+
# New implementation that replaces the inherited one
171+
...
172+
173+
Deactivating inherited checks
174+
-----------------------------
175+
176+
A child profile can also **fully deactivate** a check inherited from one of
177+
its ancestors. A deactivated check is skipped during validation and
178+
reported as such in the validation result. This is useful when an extension
179+
profile relaxes the parent's expectations, or replaces a coarse-grained
180+
check with a more specific one declared elsewhere in the same profile.
181+
182+
SHACL checks
183+
^^^^^^^^^^^^
184+
185+
Two complementary mechanisms are supported, depending on whether the shape
186+
to disable has an absolute IRI of its own.
187+
188+
**Shape with an absolute IRI** (e.g. a top-level ``NodeShape`` or a named
189+
``PropertyShape``): reference the shape by IRI from the extension profile
190+
and mark it as deactivated, without redeclaring it.
191+
192+
.. code-block:: turtle
193+
194+
# Extension profile
195+
<https://parent-profile/ShapeC> sh:deactivated true .
196+
197+
**Nested ``PropertyShape`` without an absolute IRI** (a property declared
198+
inline inside a parent ``NodeShape``): use the override-by-name mechanism
199+
described in the previous section. Declare a new ``PropertyShape`` in the
200+
extension profile with the same ``sh:name`` as the one to disable, and set
201+
``sh:deactivated true`` on it. This overrides the parent's
202+
``PropertyShape``, and the validator reports the resulting check as
203+
deactivated.
204+
205+
.. code-block:: turtle
206+
207+
# Extension profile — disables the inherited PropertyShape by sh:name
208+
ro:ShapeC
209+
a sh:NodeShape ;
210+
sh:name "The Shape C" ;
211+
sh:targetNode ro:ro-crate-metadata.json ;
212+
sh:property [
213+
a sh:PropertyShape ;
214+
sh:name "Check Metadata File Descriptor entity existence" ;
215+
sh:path rdf:type ;
216+
sh:deactivated true ;
217+
] .
218+
219+
.. note::
220+
221+
Cross-profile deactivation is scoped to the shape's transitive
222+
descendants: a ``sh:deactivated true`` triple declared by a profile
223+
that does not inherit (directly or transitively) from the shape's
224+
owning profile is ignored. This prevents unrelated profiles loaded in
225+
the same process from interfering with one another.
226+
227+
Python checks
228+
^^^^^^^^^^^^^
229+
230+
The ``@check`` decorator accepts a ``deactivated`` flag, mirroring SHACL's
231+
``sh:deactivated``. Combined with override-by-name, an extension profile
232+
can disable an inherited Python check by redeclaring it with
233+
``deactivated=True``:
234+
235+
.. code-block:: python
236+
237+
from rocrate_validator.requirements.python import check
238+
239+
@check(name="Check Metadata File Descriptor entity existence",
240+
deactivated=True)
241+
def disabled(self, ctx):
242+
# Body is irrelevant — the check is skipped during validation.
243+
return True
244+
97245
Running validator & tests during profile development
98246
----------------------------------------------------
99247

rocrate_validator/models.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,14 @@ def siblings(self) -> list[Profile]:
456456
"""
457457
return self.get_sibling_profiles(self)
458458

459+
@property
460+
def descendants(self) -> list[Profile]:
461+
"""
462+
The list of profiles that are descendants of this profile
463+
(i.e., profiles that have this profile among their inherited profiles).
464+
"""
465+
return self.get_descendants(self)
466+
459467
@property
460468
def readme_file_path(self) -> Path:
461469
"""
@@ -868,6 +876,20 @@ def get_sibling_profiles(cls, profile: Profile) -> list[Profile]:
868876
"""
869877
return [p for p in cls.__profiles_map.values() if profile in p.parents]
870878

879+
@classmethod
880+
def get_descendants(cls, profile: Profile) -> list[Profile]:
881+
"""
882+
Get the transitive descendants of the given profile (any profile
883+
that has `profile` among its `inherited_profiles`).
884+
885+
:param profile: the profile
886+
:type profile: Profile
887+
888+
:return: the list of descendant profiles
889+
:rtype: list[Profile]
890+
"""
891+
return [p for p in cls.__profiles_map.values() if profile in p.inherited_profiles]
892+
871893
@classmethod
872894
def all(cls) -> list[Profile]:
873895
"""
@@ -1088,6 +1110,10 @@ def _do_validate_(self, context: ValidationContext) -> bool:
10881110
[_.identifier for _ in check.overridden_by],
10891111
)
10901112
continue
1113+
if check.deactivated:
1114+
logger.debug("Skipping check '%s' because deactivated", check.identifier)
1115+
context.result._add_skipped_check(check)
1116+
continue
10911117
# Determine whether to skip event notification for inherited profiles
10921118
skip_event_notify = False
10931119
if (
@@ -1358,13 +1384,15 @@ def __init__(
13581384
level: Optional[RequirementLevel] = LevelCollection.REQUIRED,
13591385
description: Optional[str] = None,
13601386
hidden: Optional[bool] = None,
1387+
deactivated: bool = False,
13611388
):
13621389
self._requirement: Requirement = requirement
13631390
self._order_number = 0
13641391
self._name = name
13651392
self._level = level
13661393
self._description = description
13671394
self._hidden = hidden
1395+
self._deactivated = deactivated
13681396

13691397
@property
13701398
def order_number(self) -> int:
@@ -1430,6 +1458,10 @@ def overrides(self) -> list[RequirementCheck]:
14301458
def overridden(self) -> bool:
14311459
return len(self.overridden_by) > 0
14321460

1461+
@property
1462+
def deactivated(self) -> bool:
1463+
return self._deactivated
1464+
14331465
@property
14341466
def hidden(self) -> bool:
14351467
if self._hidden is not None:
@@ -2925,6 +2957,12 @@ def __do_validate__(self, requirements: Optional[list[Requirement]] = None) -> V
29252957
# set the profiles to validate against
29262958
profiles = context.profiles
29272959
assert len(profiles) > 0, "No profiles to validate"
2960+
# Pre-load every profile's requirements so all shape graphs are
2961+
# populated before the validation loop runs. This lets a check
2962+
# see `sh:deactivated true` triples declared by descendant
2963+
# profiles that have not yet been visited.
2964+
for p in profiles:
2965+
_ = p.requirements
29282966
self.notify(EventType.VALIDATION_START)
29292967
for profile in profiles:
29302968
logger.debug(

rocrate_validator/requirements/python/__init__.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@ def __init__(self,
3838
name: str,
3939
check_function: Callable[[RequirementCheck, ValidationContext], bool],
4040
description: Optional[str] = None,
41-
level: Optional[LevelCollection] = LevelCollection.REQUIRED):
41+
level: Optional[LevelCollection] = LevelCollection.REQUIRED,
42+
deactivated: bool = False):
4243
"""
4344
check_function: a function that accepts an instance of PyFunctionCheck and a ValidationContext.
4445
"""
45-
super().__init__(requirement, name, description=description, level=level)
46+
super().__init__(requirement, name, description=description, level=level, deactivated=deactivated)
4647

4748
sig = inspect.signature(check_function)
4849
if len(sig.parameters) != 2:
@@ -115,11 +116,13 @@ def __init_checks__(self):
115116
f"Getting severity from path: {self.severity_from_path}")
116117
severity = self.severity_from_path or Severity.REQUIRED
117118
logger.debug("Severity log: %r", severity)
119+
deactivated = bool(getattr(member, "deactivated", False))
118120
check = self.requirement_check_class(self,
119121
check_name,
120122
member,
121123
description=check_description,
122-
level=LevelCollection.get(severity.name) if severity else None)
124+
level=LevelCollection.get(severity.name) if severity else None,
125+
deactivated=deactivated)
123126
self._checks.append(check)
124127
logger.debug("Added check: %s %r", check_name, check)
125128

@@ -159,7 +162,9 @@ def decorator(cls):
159162
return decorator
160163

161164

162-
def check(name: Optional[str] = None, severity: Optional[Severity] = None):
165+
def check(name: Optional[str] = None,
166+
severity: Optional[Severity] = None,
167+
deactivated: bool = False):
163168
"""
164169
A decorator to mark a function as a check.
165170
@@ -178,6 +183,12 @@ def check(name: Optional[str] = None, severity: Optional[Severity] = None):
178183
:param severity: the severity level
179184
:type severity: Optional[Severity]
180185
186+
:param deactivated: when True, the check is skipped during validation.
187+
Mirrors SHACL's ``sh:deactivated``: an extension profile may redeclare
188+
a check with the same name as one in a parent profile and set this
189+
flag to disable the inherited check.
190+
:type deactivated: bool
191+
181192
:return: the decorated function
182193
:rtype: Callable
183194
"""
@@ -193,6 +204,7 @@ def decorator(func):
193204
func.check = True
194205
func.name = check_name
195206
func.severity = severity
207+
func.deactivated = deactivated
196208
return func
197209
return decorator
198210

rocrate_validator/requirements/shacl/checks.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
from timeit import default_timer as timer
1717
from typing import Optional
1818

19+
from rdflib import Literal, Namespace
20+
21+
from rocrate_validator.constants import SHACL_NS
1922
from rocrate_validator.errors import ROCrateMetadataNotFoundError
2023
from rocrate_validator.events import EventType
2124
from rocrate_validator.models import (
@@ -27,7 +30,7 @@
2730
SkipRequirementCheck,
2831
ValidationContext,
2932
)
30-
from rocrate_validator.requirements.shacl.models import Shape
33+
from rocrate_validator.requirements.shacl.models import Shape, ShapesRegistry
3134
from rocrate_validator.requirements.shacl.utils import make_uris_relative, resolve_parent_shape
3235
from rocrate_validator.requirements.shacl.validator import (
3336
SHACLValidationAlreadyProcessed,
@@ -41,6 +44,9 @@
4144

4245
logger = logging.getLogger(__name__)
4346

47+
_SH = Namespace(SHACL_NS)
48+
_TRUE_LITERALS = (Literal(True), Literal("true", datatype=None))
49+
4450

4551
class SHACLCheck(RequirementCheck):
4652
"""
@@ -98,6 +104,37 @@ def shape(self) -> Shape:
98104
def root(self) -> bool:
99105
return self._root
100106

107+
@property
108+
def deactivated(self) -> bool:
109+
if self._deactivated:
110+
return True
111+
shape = self._shape
112+
if shape is None:
113+
return False
114+
# Same-profile deactivation (cases B & C): the shape itself carries
115+
# `sh:deactivated true`, possibly because it was redeclared in an
116+
# extension profile via override-by-name.
117+
for value in shape.graph.objects(subject=shape.node, predicate=_SH.deactivated):
118+
if isinstance(value, Literal) and bool(value.toPython()):
119+
return True
120+
# Cross-profile deactivation (case A): a descendant profile may add
121+
# `<parentShapeIRI> sh:deactivated true` to its own shapes graph,
122+
# without redeclaring the shape. Scan only profiles that inherit
123+
# (transitively) from the shape's owning profile, so unrelated
124+
# profiles loaded in the same process can't influence the result.
125+
# Validator.__do_validate__ pre-loads the shape graphs.
126+
from rocrate_validator.models import Profile
127+
128+
owning_profile = self.requirement.profile
129+
for profile in Profile.get_descendants(owning_profile):
130+
try:
131+
registry = ShapesRegistry.get_instance(profile)
132+
except Exception:
133+
continue
134+
if registry.is_node_deactivated(shape.node):
135+
return True
136+
return False
137+
101138
@property
102139
def description(self) -> str:
103140
if self._shape.description:
@@ -357,12 +394,16 @@ def __do_execute_check__(self, shacl_context: SHACLValidationContext):
357394
# all together and not profile by profile
358395
if requirementCheck.identifier not in failed_requirement_checks_notified:
359396
shacl_context.result._add_executed_check(requirementCheck, False)
360-
if requirementCheck.identifier not in failed_requirement_checks_notified and \
361-
requirementCheck.requirement.profile != shacl_context.current_validation_profile:
397+
if (
398+
requirementCheck.identifier not in failed_requirement_checks_notified
399+
and requirementCheck.requirement.profile != shacl_context.current_validation_profile
400+
):
362401
failed_requirement_checks_notified.append(requirementCheck.identifier)
363-
shacl_context.validator.notify(RequirementCheckValidationEvent(
364-
EventType.REQUIREMENT_CHECK_VALIDATION_END,
365-
requirementCheck, validation_result=False))
402+
shacl_context.validator.notify(
403+
RequirementCheckValidationEvent(
404+
EventType.REQUIREMENT_CHECK_VALIDATION_END, requirementCheck, validation_result=False
405+
)
406+
)
366407
logger.debug(
367408
"Added failed check to the context: %s",
368409
requirementCheck.identifier,

0 commit comments

Comments
 (0)