diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e30287..c3464b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Python PEP 440 Versioning](https://www.python.org/d ### Fixed - SPARQL constraint validation no longer misidentifies `VALUES` in property paths or predicate names. - Fixes #301 +- Validation report text output is now deterministic. + - Fixes #304 ## [0.30.1] - 2025-03-15 diff --git a/pyshacl/constraints/constraint_component.py b/pyshacl/constraints/constraint_component.py index 2448f0c7..53e0aeb9 100644 --- a/pyshacl/constraints/constraint_component.py +++ b/pyshacl/constraints/constraint_component.py @@ -196,7 +196,8 @@ def make_v_result_description( sc_text = stringify_node(sg, source_constraint) desc += "\tSource Constraint: {}\n".format(sc_text) if extra_messages: - for m in iter(extra_messages): + sorted_extra_messages = sorted(extra_messages, key=lambda m: str(m)) + for m in iter(sorted_extra_messages): if m in messages: continue if isinstance(m, Literal): @@ -206,7 +207,8 @@ def make_v_result_description( desc += "\tMessage: {}\n".format(msg) else: # pragma: no cover desc += "\tMessage: {}\n".format(str(m)) - for m in messages: + sorted_messages = sorted(messages, key=lambda m: str(m)) + for m in sorted_messages: if isinstance(m, Literal): msg = str(m.value) if bound_vars is not None: diff --git a/pyshacl/validator.py b/pyshacl/validator.py index 6c573409..ec423f75 100644 --- a/pyshacl/validator.py +++ b/pyshacl/validator.py @@ -114,9 +114,12 @@ def create_validation_report(cls, sg, conforms: bool, results: List[Tuple]): vg.add((vr, RDF_type, SH_ValidationReport)) vg.add((vr, SH_conforms, Literal(conforms))) cloned_nodes: Dict[Tuple[GraphLike, str], Union[BNode, URIRef]] = {} - for result in iter(results): + text_results = sorted(results, key=lambda r: r[0]) + for result in iter(text_results): _d, _bn, _tr = result v_text += _d + for result in iter(results): + _d, _bn, _tr = result vg.add((vr, SH_result, _bn)) for tr in iter(_tr): s, p, o = tr diff --git a/test/issues/test_304.py b/test/issues/test_304.py new file mode 100644 index 00000000..c240d801 --- /dev/null +++ b/test/issues/test_304.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# +""" +https://github.com/RDFLib/pySHACL/issues/304 +""" +import os +import subprocess +import sys +import textwrap + + +DATA_TTL = """\ +@prefix ex: . + +ex:node1 a ex:Thing ; + ex:list ( "a" "b" ) ; + ex:code "X-1" ; + ex:flag true . + +ex:node2 a ex:Thing ; + ex:list ( "c" "d" ) ; + ex:code "BAD" . + +ex:node3 a ex:Thing ; + ex:list ( "e" ) . + +ex:node4 a ex:Other ; + ex:label 42 ; + ex:ref ex:node1 . + +ex:node5 a ex:Other ; + ex:label "ok" ; + ex:ref ex:node2 ; + ex:ref ex:node3 . +""" + +SHAPES_TTL = """\ +@prefix sh: . +@prefix ex: . +@prefix xsd: . + +ex:ThingShape + a sh:NodeShape ; + sh:targetClass ex:Thing ; + sh:property [ + sh:path ex:list ; + sh:datatype xsd:integer ; + sh:message "List items must be integers." ; + ] ; + sh:property [ + sh:path ex:code ; + sh:pattern "^X-" ; + sh:message "Code must start with X-" ; + ] ; + sh:property [ + sh:path ex:flag ; + sh:minCount 1 ; + sh:message "Flag is required." ; + ] . + +ex:OtherShape + a sh:NodeShape ; + sh:targetClass ex:Other ; + sh:property [ + sh:path ex:label ; + sh:datatype xsd:string ; + sh:message "Label must be a string." ; + ] ; + sh:property [ + sh:path ex:ref ; + sh:maxCount 1 ; + sh:message "Only one ref allowed." ; + ] . +""" + + +def _run_validate_with_seed(seed: str) -> str: + script = textwrap.dedent( + f""" + from rdflib import Graph + from pyshacl import validate + + data = {DATA_TTL!r} + shapes = {SHAPES_TTL!r} + data_g = Graph().parse(data=data, format="turtle") + shapes_g = Graph().parse(data=shapes, format="turtle") + conforms, report_graph, results_text = validate( + data_g, + shacl_graph=shapes_g, + advanced=False, + debug=False, + ) + print(results_text) + """ + ) + env = os.environ.copy() + env["PYTHONHASHSEED"] = seed + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=True, + env=env, + ) + return result.stdout + + +def test_304(): + out_a = _run_validate_with_seed("1") + out_b = _run_validate_with_seed("2") + out_c = _run_validate_with_seed("3") + assert "Results (" in out_a + assert out_a == out_b + assert out_a == out_c