From ca8ebad72923ae760bc9766a6e878efcc4922d58 Mon Sep 17 00:00:00 2001 From: Ashley Sommer Date: Fri, 16 Jan 2026 16:24:04 +1000 Subject: [PATCH] Enhance shape filtering logic to prevent failures when omitted PropertyShapes are referenced. Update validation components to skip filtered shapes. Add test for issue #298 to ensure correct behavior. --- CHANGELOG.md | 2 + .../constraints/core/logical_constraints.py | 24 +++++-- pyshacl/constraints/core/other_constraints.py | 2 + .../core/shape_based_constraints.py | 30 +++++--- pyshacl/shape.py | 2 + pyshacl/shapes_graph.py | 17 +++++ test/issues/test_298.py | 70 +++++++++++++++++++ test/issues/test_301.py | 1 - test/issues/test_304.py | 3 + 9 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 test/issues/test_298.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c3464b58..18a75be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Python PEP 440 Versioning](https://www.python.org/d - Fixes #301 - Validation report text output is now deterministic. - Fixes #304 +- `use_shapes` filtering no longer fails when omitted PropertyShapes are referenced by included shapes. + - Fixes #298 ## [0.30.1] - 2025-03-15 diff --git a/pyshacl/constraints/core/logical_constraints.py b/pyshacl/constraints/core/logical_constraints.py index 0f7b8538..981e3e40 100644 --- a/pyshacl/constraints/core/logical_constraints.py +++ b/pyshacl/constraints/core/logical_constraints.py @@ -82,6 +82,8 @@ def evaluate(self, executor: SHACLExecutor, datagraph: GraphLike, focus_value_no potentially_recursive = self.recursion_triggers(_evaluation_path) for not_c in self.not_list: + if self.shape.sg.is_filtered_out_shape(not_c): + continue _nc, _r = self._evaluate_not_constraint( executor, not_c, datagraph, focus_value_nodes, potentially_recursive, _evaluation_path ) @@ -101,19 +103,20 @@ def _evaluate_not_constraint( """ _reports = [] _non_conformant = False - not_shape = self.shape.get_other_shape(not_c) - if not not_shape: + found_not_shape = self.shape.get_other_shape(not_c) + if not found_not_shape: raise ReportableRuntimeError( "Shape pointed to by sh:not does not exist or is not a well-formed SHACL Shape." + f"Please check if the shape '{not_c}' is defined." ) - if potentially_recursive and not_shape in potentially_recursive: + if potentially_recursive and found_not_shape in potentially_recursive: warn(ShapeRecursionWarning(_evaluation_path)) return _non_conformant, _reports upstream_reports = [] for f, value_nodes in focus_value_nodes.items(): for v in value_nodes: try: - _is_conform, _r = not_shape.validate( + _is_conform, _r = found_not_shape.validate( executor, datagraph, focus=v, _evaluation_path=_evaluation_path[:] ) except ValidationFailure as e: @@ -213,12 +216,17 @@ def _evaluate_and_constraint(self, executor, and_c, target_graph, focus_value_no raise ReportableRuntimeError("The list associated with sh:and is not a valid RDF list.") and_shapes = set() for a in and_list: + if self.shape.sg.is_filtered_out_shape(a): + continue and_shape = self.shape.get_other_shape(a) if not and_shape: raise ReportableRuntimeError( "Shape pointed to by sh:and does not exist or is not a well-formed SHACL Shape." ) and_shapes.add(and_shape) + if not and_shapes: + # All filtered out, no reports to send + return _non_conformant, _reports upstream_reports = [] for f, value_nodes in focus_value_nodes.items(): for v in value_nodes: @@ -323,12 +331,16 @@ def _evaluate_or_constraint(self, executor, or_c, target_graph, focus_value_node raise ReportableRuntimeError("The list associated with sh:or is not a valid RDF list.") or_shapes = set() for o in or_list: + if self.shape.sg.is_filtered_out_shape(o): + continue or_shape = self.shape.get_other_shape(o) if not or_shape: raise ReportableRuntimeError( "Shape pointed to by sh:or does not exist or is not a well-formed SHACL Shape." ) or_shapes.add(or_shape) + if not or_shapes: + return _non_conformant, _reports upstream_reports = [] for f, value_nodes in focus_value_nodes.items(): for v in value_nodes: @@ -435,12 +447,16 @@ def _evaluate_xone_constraint(self, executor, xone_c, target_graph, focus_value_ raise ReportableRuntimeError("The list associated with sh:xone is not a valid RDF list.") xone_shapes = list() for x in xone_list: + if self.shape.sg.is_filtered_out_shape(x): + continue xone_shape = self.shape.get_other_shape(x) if not xone_shape: raise ReportableRuntimeError( "Shape pointed to by sh:xone does not exist or is not a well-formed SHACL Shape." ) xone_shapes.append(xone_shape) + if not xone_shapes: + return _non_conformant, _reports upstream_reports = [] for f, value_nodes in focus_value_nodes.items(): for v in value_nodes: diff --git a/pyshacl/constraints/core/other_constraints.py b/pyshacl/constraints/core/other_constraints.py index 99311e34..8b9dcc3c 100644 --- a/pyshacl/constraints/core/other_constraints.py +++ b/pyshacl/constraints/core/other_constraints.py @@ -177,6 +177,8 @@ def evaluate( working_shapes = set() for p_shape in self.property_shapes: + if self.shape.sg.is_filtered_out_shape(p_shape): + continue property_shape = self.shape.get_other_shape(p_shape) if not property_shape or not property_shape.is_property_shape: raise ReportableRuntimeError( diff --git a/pyshacl/constraints/core/shape_based_constraints.py b/pyshacl/constraints/core/shape_based_constraints.py index d359ae64..346a81e6 100644 --- a/pyshacl/constraints/core/shape_based_constraints.py +++ b/pyshacl/constraints/core/shape_based_constraints.py @@ -96,6 +96,8 @@ def evaluate( potentially_recursive = self.recursion_triggers(_evaluation_path) for p_shape in self.property_shapes: + if self.shape.sg.is_filtered_out_shape(p_shape): + continue _nc, _r = self._evaluate_property_shape( executor, p_shape, target_graph, focus_value_nodes, potentially_recursive, _evaluation_path ) @@ -108,16 +110,16 @@ def _evaluate_property_shape( ): _reports = [] _non_conformant = False - prop_shape = self.shape.get_other_shape(prop_shape) - if potentially_recursive and prop_shape in potentially_recursive: + found_prop_shape = self.shape.get_other_shape(prop_shape) + if potentially_recursive and found_prop_shape in potentially_recursive: warn(ShapeRecursionWarning(_evaluation_path)) return _non_conformant, _reports - if not prop_shape: + if not found_prop_shape: raise ReportableRuntimeError( f"SHACL PropertyShape not found: The shape referenced by sh:property does not exist. " f"Please check if the shape '{prop_shape}' is defined." ) - elif not prop_shape.is_property_shape: + elif not found_prop_shape.is_property_shape: raise ReportableRuntimeError( f"'{prop_shape}' exists but is not a well-formed SHACL PropertyShape. " f"Ensure it has the correct type (sh:PropertyShape) and all required properties." @@ -125,7 +127,7 @@ def _evaluate_property_shape( for f, value_nodes in focus_value_nodes.items(): for v in value_nodes: - _is_conform, _r = prop_shape.validate( + _is_conform, _r = found_prop_shape.validate( executor, target_graph, focus=v, _evaluation_path=_evaluation_path[:] ) _non_conformant = _non_conformant or (not _is_conform) @@ -195,6 +197,8 @@ def evaluate( potentially_recursive = self.recursion_triggers(_evaluation_path) for n_shape in self.node_shapes: + if self.shape.sg.is_filtered_out_shape(n_shape): + continue _nc, _r = self._evaluate_node_shape( executor, n_shape, target_graph, focus_value_nodes, potentially_recursive, _evaluation_path ) @@ -207,17 +211,20 @@ def _evaluate_node_shape( ): _reports = [] _non_conformant = False - node_shape = self.shape.get_other_shape(node_shape) - if potentially_recursive and node_shape in potentially_recursive: + found_node_shape = self.shape.get_other_shape(node_shape) + if potentially_recursive and found_node_shape in potentially_recursive: warn(ShapeRecursionWarning(_evaluation_path)) return _non_conformant, _reports - if not node_shape or node_shape.is_property_shape: + if not found_node_shape: raise ReportableRuntimeError( - "Shape pointed to by sh:node does not exist or is not a well-formed SHACL NodeShape." + f"SHACL Shape not found: The shape referenced by sh:node does not exist. " + f"Please check if the shape '{node_shape}' is defined." ) + elif found_node_shape.is_property_shape: + raise ReportableRuntimeError("Shape pointed to by sh:node is not a well-formed SHACL NodeShape.") for f, value_nodes in focus_value_nodes.items(): for v in value_nodes: - _is_conform, _r = node_shape.validate( + _is_conform, _r = found_node_shape.validate( executor, target_graph, focus=v, _evaluation_path=_evaluation_path[:] ) # Create a failure for this constraint component if any failures exist @@ -363,6 +370,8 @@ def evaluate( potentially_recursive = self.recursion_triggers(_evaluation_path) for v_shape in self.value_shapes: + if self.shape.sg.is_filtered_out_shape(v_shape): + continue _nc, _r = self._evaluate_value_shape( executor, v_shape, target_graph, focus_value_nodes, potentially_recursive, _evaluation_path ) @@ -398,6 +407,7 @@ def _evaluate_value_shape( sibling_shapes.add(sibling) sibling_shapes = set(self.shape.get_other_shape(s) for s in sibling_shapes) + sibling_shapes = {s for s in sibling_shapes if s is not None} else: sibling_shapes = set() upstream_reports = [] diff --git a/pyshacl/shape.py b/pyshacl/shape.py index a332a591..8c13cb28 100644 --- a/pyshacl/shape.py +++ b/pyshacl/shape.py @@ -130,6 +130,8 @@ def set_advanced(self, val): self._advanced = bool(val) def get_other_shape(self, shape_node): + if self.sg.is_filtered_out_shape(shape_node): + return None try: return self.sg.lookup_shape_from_node(shape_node) except (KeyError, AttributeError): diff --git a/pyshacl/shapes_graph.py b/pyshacl/shapes_graph.py index d3ed90a0..7fb77801 100644 --- a/pyshacl/shapes_graph.py +++ b/pyshacl/shapes_graph.py @@ -60,6 +60,7 @@ def __init__(self, graph, debug: Optional[Union[bool, int]] = False, logger: Opt self._custom_constraints = None self._shacl_functions: Dict[str, tuple] = {} self._shacl_target_types: Dict[str, 'RDFNode'] = {} + self._filtered_out_shapes: set = set() self._use_js = False self._add_system_triples() @@ -182,6 +183,9 @@ def shapes_from_uris(self, shapes_uris: List[rdflib.URIRef]): self._build_node_shape_cache_from_list(shapes_uris) return [self._node_shape_cache[s] for s in shapes_uris] + def is_filtered_out_shape(self, node) -> bool: + return isinstance(node, rdflib.URIRef) and node in self._filtered_out_shapes + def lookup_shape_from_node(self, node) -> Shape: # This will throw a KeyError if it is not found. This is intentionally not caught here. return self._node_shape_cache[node] @@ -201,6 +205,7 @@ def _build_node_shape_cache(self): :rtype: NoneType """ g = self.graph + self._filtered_out_shapes = set() defined_node_shapes = set(g.subjects(RDF_type, SH_NodeShape)) if self.debug: self.logger.debug(f"Found {len(defined_node_shapes)} SHACL Shapes defined with type sh:NodeShape.") @@ -428,6 +433,18 @@ def _gather_shapes(shapes_nodes: Sequence[Union[rdflib.URIRef, rdflib.BNode]], r _gather_shapes(shapes_list) + allowed_shapes = set(gathered_node_shapes).union(set(gathered_prop_shapes)) + allowed_shape_uris = {s for s in allowed_shapes if isinstance(s, rdflib.URIRef)} + referenced_shape_uris = set() + for _p in (SH_property, SH_node, SH_not, SH_qualifiedValueShape): + referenced_shape_uris.update(o for o in g.objects(None, _p) if isinstance(o, rdflib.URIRef)) + for _p in (SH_and, SH_or, SH_xone): + for list_node in g.objects(None, _p): + for item in g.items(list_node): + if isinstance(item, rdflib.URIRef): + referenced_shape_uris.add(item) + self._filtered_out_shapes = referenced_shape_uris.difference(allowed_shape_uris) + for s in gathered_node_shapes: path_vals = list(g.objects(s, SH_path)) if len(path_vals) > 0: diff --git a/test/issues/test_298.py b/test/issues/test_298.py new file mode 100644 index 00000000..fdef6a35 --- /dev/null +++ b/test/issues/test_298.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +""" +https://github.com/RDFLib/pySHACL/issues/298 +""" +import sys + +from rdflib import Graph +from rdflib.plugins.parsers.jsonld import to_rdf + +import pyshacl + + +def test_298(): + shapes_graph = to_rdf( + { + "@context": { + "ex": "http://example.org/", + "sh": "http://www.w3.org/ns/shacl#", + }, + "@graph": [ + { + "@id": "ex:PersonShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Person"}, + "sh:property": [ + { + "@id": "ex:NameProperty", + "sh:path": {"@id": "ex:name"}, + "sh:minCount": 1, + }, + { + "@id": "ex:AgeProperty", + "sh:path": {"@id": "ex:age"}, + "sh:minInclusive": 18, + }, + ], + } + ], + }, + Graph(), + ) + + data_graph = to_rdf( + { + "@context": { + "ex": "http://example.org/", + }, + "@id": "ex:person1", + "@type": "ex:Person", + "ex:name": "John Doe", + "ex:age": 25, + }, + Graph(), + ) + + conforms, report_graph, results_text = pyshacl.validate( + data_graph, + shacl_graph=shapes_graph, + use_shapes=[ + "http://example.org/PersonShape", + "http://example.org/NameProperty", + ], + ) + assert conforms + assert "Validation Report" in results_text + + +if __name__ == "__main__": + sys.exit(test_298()) diff --git a/test/issues/test_301.py b/test/issues/test_301.py index 859f6531..c0d659aa 100644 --- a/test/issues/test_301.py +++ b/test/issues/test_301.py @@ -59,5 +59,4 @@ def test_301(): if __name__ == "__main__": - test_301() sys.exit(test_301()) diff --git a/test/issues/test_304.py b/test/issues/test_304.py index c240d801..178e1e65 100644 --- a/test/issues/test_304.py +++ b/test/issues/test_304.py @@ -112,3 +112,6 @@ def test_304(): assert "Results (" in out_a assert out_a == out_b assert out_a == out_c + +if __name__ == "__main__": + sys.exit(test_304())