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