validibot-shared 0.10.0__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/PKG-INFO +3 -2
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/pyproject.toml +3 -3
- validibot_shared-0.12.0/tests/test_schematron_envelopes.py +204 -0
- validibot_shared-0.12.0/tests/test_schematron_svrl.py +288 -0
- validibot_shared-0.12.0/validibot_shared/schematron/__init__.py +49 -0
- validibot_shared-0.12.0/validibot_shared/schematron/envelopes.py +296 -0
- validibot_shared-0.12.0/validibot_shared/schematron/svrl.py +277 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/validations/envelopes.py +1 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/.gitignore +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/LICENSE +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/NOTICE +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/README.md +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_energyplus_envelopes.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_energyplus_models.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_evidence_manifest.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_fmu_envelopes.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_fmu_models.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_package_init.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_shacl_envelopes.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/tests/test_validations_envelopes.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/energyplus/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/energyplus/envelopes.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/energyplus/models.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/evidence/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/evidence/manifest.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/fmu/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/fmu/envelopes.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/fmu/models.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/py.typed +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/shacl/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/shacl/envelopes.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/validations/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: validibot-shared
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Shared library for data interchange between Validibot and validator containers
|
|
5
5
|
Project-URL: Homepage, https://validibot.com
|
|
6
6
|
Project-URL: Documentation, https://docs.validibot.com
|
|
@@ -24,10 +24,11 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
24
24
|
Classifier: Topic :: Scientific/Engineering
|
|
25
25
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
26
|
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: defusedxml<0.8,>=0.7.1
|
|
27
28
|
Requires-Dist: pydantic<3.0,>=2.13
|
|
28
29
|
Provides-Extra: dev
|
|
29
30
|
Requires-Dist: pytest==9.1.1; extra == 'dev'
|
|
30
|
-
Requires-Dist: ruff==0.15.
|
|
31
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
31
32
|
Description-Content-Type: text/markdown
|
|
32
33
|
|
|
33
34
|
<div align="center">
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "validibot-shared"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.0"
|
|
8
8
|
description = "Shared library for data interchange between Validibot and validator containers"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -34,10 +34,10 @@ classifiers = [
|
|
|
34
34
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
35
|
"Topic :: Scientific/Engineering",
|
|
36
36
|
]
|
|
37
|
-
dependencies = ["pydantic>=2.13,<3.0"]
|
|
37
|
+
dependencies = ["pydantic>=2.13,<3.0", "defusedxml>=0.7.1,<0.8"]
|
|
38
38
|
|
|
39
39
|
[project.optional-dependencies]
|
|
40
|
-
dev = ["pytest==9.1.1", "ruff==0.15.
|
|
40
|
+
dev = ["pytest==9.1.1", "ruff==0.15.20"]
|
|
41
41
|
|
|
42
42
|
[project.urls]
|
|
43
43
|
Homepage = "https://validibot.com"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Tests for the Schematron container contract (ADR-2026-07-01, D4b/D9).
|
|
2
|
+
|
|
3
|
+
The Schematron validator ships the author's rules **inline as text** (the
|
|
4
|
+
SHACL ``shapes_text`` pattern — the container compiles them itself), and its
|
|
5
|
+
outputs carry an ``engine_status`` failure taxonomy where ``passed`` is
|
|
6
|
+
*tri-state* — True/False when the engine ran, ``None`` (unknown) when it
|
|
7
|
+
could not. These tests pin those contract properties so a refactor cannot
|
|
8
|
+
silently weaken them: the Django side and the validator backend both program
|
|
9
|
+
against exactly this shape.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from validibot_shared.schematron.envelopes import (
|
|
16
|
+
ENGINE_ERROR_RULES_INVALID,
|
|
17
|
+
ENGINE_STATUS_OK,
|
|
18
|
+
ENGINE_STATUS_TIMEOUT,
|
|
19
|
+
SchematronFinding,
|
|
20
|
+
SchematronInputEnvelope,
|
|
21
|
+
SchematronInputs,
|
|
22
|
+
SchematronOutputEnvelope,
|
|
23
|
+
SchematronOutputs,
|
|
24
|
+
build_schematron_input_envelope,
|
|
25
|
+
)
|
|
26
|
+
from validibot_shared.validations.envelopes import (
|
|
27
|
+
SupportedMimeType,
|
|
28
|
+
ValidationStatus,
|
|
29
|
+
ValidatorType,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Test constants to avoid magic values
|
|
33
|
+
DEFAULT_MAX_FINDINGS = 500
|
|
34
|
+
DEFAULT_XSLT_TIMEOUT = 60
|
|
35
|
+
TEST_ERROR_COUNT = 3
|
|
36
|
+
RULES_SHA = "b" * 64
|
|
37
|
+
|
|
38
|
+
SCH_SOURCE = (
|
|
39
|
+
'<schema xmlns="http://purl.oclc.org/dsdl/schematron">'
|
|
40
|
+
"<pattern><rule context='/'><assert test='true()'>ok</assert></rule>"
|
|
41
|
+
"</pattern></schema>"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _ValidatorStub:
|
|
46
|
+
"""Duck-typed validator (id/validation_type/version) for the builder."""
|
|
47
|
+
|
|
48
|
+
id = "val-1"
|
|
49
|
+
validation_type = "SCHEMATRON"
|
|
50
|
+
version = "1"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _inputs(**overrides) -> SchematronInputs:
|
|
54
|
+
base = {
|
|
55
|
+
"schematron_text": SCH_SOURCE,
|
|
56
|
+
"schematron_sha256": RULES_SHA,
|
|
57
|
+
}
|
|
58
|
+
base.update(overrides)
|
|
59
|
+
return SchematronInputs(**base)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_inputs_carry_inline_rules_and_limit_defaults():
|
|
63
|
+
"""Inputs ship the rules inline with provenance sha and D8 defaults.
|
|
64
|
+
|
|
65
|
+
Inline text is the whole delivery model (no staging, no artefact URIs):
|
|
66
|
+
the container gets everything it needs from the envelope alone, exactly
|
|
67
|
+
like SHACL's shapes_text.
|
|
68
|
+
"""
|
|
69
|
+
inputs = _inputs()
|
|
70
|
+
|
|
71
|
+
assert inputs.schematron_text == SCH_SOURCE
|
|
72
|
+
assert inputs.schematron_sha256 == RULES_SHA
|
|
73
|
+
assert inputs.max_findings == DEFAULT_MAX_FINDINGS
|
|
74
|
+
assert inputs.xslt_timeout_seconds == DEFAULT_XSLT_TIMEOUT
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_inputs_require_the_rules_text():
|
|
78
|
+
"""Omitting schematron_text is a validation error, not a default.
|
|
79
|
+
|
|
80
|
+
An input envelope without rules would force the container to run
|
|
81
|
+
nothing and report... something. Refuse at the contract layer.
|
|
82
|
+
"""
|
|
83
|
+
with pytest.raises(ValidationError):
|
|
84
|
+
SchematronInputs()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_outputs_default_to_unknown_passed_not_false():
|
|
88
|
+
"""``passed`` defaults to None — unknown, not failed (D9).
|
|
89
|
+
|
|
90
|
+
A default of False would make an unpopulated envelope read as "the
|
|
91
|
+
document failed the rules"; None forces every consumer to distinguish
|
|
92
|
+
"engine didn't run" from "rules failed".
|
|
93
|
+
"""
|
|
94
|
+
outputs = SchematronOutputs()
|
|
95
|
+
assert outputs.passed is None
|
|
96
|
+
assert outputs.engine_status == ENGINE_STATUS_OK
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_engine_failure_shapes_round_trip():
|
|
100
|
+
"""Timeout and rules-invalid outputs serialize and re-validate losslessly.
|
|
101
|
+
|
|
102
|
+
The callback path deserializes output.json with this model; the D9
|
|
103
|
+
fields must survive the JSON round trip exactly, including the
|
|
104
|
+
machine hint that lets Django distinguish "your rules don't compile"
|
|
105
|
+
from a generic engine error.
|
|
106
|
+
"""
|
|
107
|
+
timeout = SchematronOutputs(
|
|
108
|
+
engine_status=ENGINE_STATUS_TIMEOUT,
|
|
109
|
+
engine_message="Transform exceeded 60s",
|
|
110
|
+
passed=None,
|
|
111
|
+
)
|
|
112
|
+
invalid_rules = SchematronOutputs(
|
|
113
|
+
engine_status="error",
|
|
114
|
+
engine_error_code=ENGINE_ERROR_RULES_INVALID,
|
|
115
|
+
engine_message="Schematron failed to compile: unexpected element",
|
|
116
|
+
passed=None,
|
|
117
|
+
)
|
|
118
|
+
for outputs, status in (
|
|
119
|
+
(timeout, ValidationStatus.FAILED_RUNTIME),
|
|
120
|
+
(invalid_rules, ValidationStatus.FAILED_RUNTIME),
|
|
121
|
+
):
|
|
122
|
+
envelope = SchematronOutputEnvelope(
|
|
123
|
+
run_id="run-1",
|
|
124
|
+
validator={"id": "v1", "type": ValidatorType.SCHEMATRON, "version": "1"},
|
|
125
|
+
status=status,
|
|
126
|
+
timing={},
|
|
127
|
+
outputs=outputs,
|
|
128
|
+
)
|
|
129
|
+
restored = SchematronOutputEnvelope.model_validate(
|
|
130
|
+
envelope.model_dump(mode="json"),
|
|
131
|
+
)
|
|
132
|
+
assert restored.outputs.engine_status == outputs.engine_status
|
|
133
|
+
assert restored.outputs.engine_error_code == outputs.engine_error_code
|
|
134
|
+
assert restored.outputs.passed is None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_findings_map_and_provenance_round_trip():
|
|
138
|
+
"""Findings keep native ids/locations; provenance keeps the rules sha."""
|
|
139
|
+
outputs = SchematronOutputs(
|
|
140
|
+
engine_status=ENGINE_STATUS_OK,
|
|
141
|
+
passed=False,
|
|
142
|
+
error_count=TEST_ERROR_COUNT,
|
|
143
|
+
finding_rule_ids_by_severity={"BR-CO-15": "ERROR", "BR-05": "WARNING"},
|
|
144
|
+
findings=[
|
|
145
|
+
SchematronFinding(
|
|
146
|
+
rule_id="BR-CO-15",
|
|
147
|
+
message="Totals must reconcile.",
|
|
148
|
+
severity="ERROR",
|
|
149
|
+
location_xpath="/Invoice/LegalMonetaryTotal",
|
|
150
|
+
flag="fatal",
|
|
151
|
+
),
|
|
152
|
+
],
|
|
153
|
+
schematron_sha256=RULES_SHA,
|
|
154
|
+
query_binding="xslt2",
|
|
155
|
+
engine="SaxonC-HE 12.9",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
restored = SchematronOutputs.model_validate(outputs.model_dump(mode="json"))
|
|
159
|
+
assert restored.finding_rule_ids_by_severity["BR-CO-15"] == "ERROR"
|
|
160
|
+
assert restored.findings[0].rule_id == "BR-CO-15"
|
|
161
|
+
assert restored.findings[0].location_xpath == "/Invoice/LegalMonetaryTotal"
|
|
162
|
+
assert restored.schematron_sha256 == RULES_SHA
|
|
163
|
+
assert restored.query_binding == "xslt2"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_outputs_forbid_unknown_fields():
|
|
167
|
+
"""extra="forbid" holds — a typo'd field fails loudly, not silently.
|
|
168
|
+
|
|
169
|
+
Contract models must reject unknown keys so a mismatched backend/Django
|
|
170
|
+
version pair surfaces as an explicit error instead of dropped data.
|
|
171
|
+
This also guards the 0.11 → 0.12 break: old pack_* fields are refused.
|
|
172
|
+
"""
|
|
173
|
+
with pytest.raises(ValidationError):
|
|
174
|
+
SchematronOutputs(engine_status="ok", pack_id="stale-field")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_build_schematron_input_envelope_assembles_the_xml_submission():
|
|
178
|
+
"""The builder produces a valid envelope with an XML primary input.
|
|
179
|
+
|
|
180
|
+
Pins the file-item conventions (name/mime/role) the container reads and
|
|
181
|
+
that ``ValidatorType.SCHEMATRON`` exists — the builder would crash
|
|
182
|
+
without the enum member.
|
|
183
|
+
"""
|
|
184
|
+
envelope = build_schematron_input_envelope(
|
|
185
|
+
run_id="run-1",
|
|
186
|
+
validator=_ValidatorStub(),
|
|
187
|
+
org_id="org-1",
|
|
188
|
+
org_name="ValidiBot",
|
|
189
|
+
workflow_id="wf-1",
|
|
190
|
+
step_id="step-1",
|
|
191
|
+
step_name="Peppol rules",
|
|
192
|
+
submission_uri="gs://bucket/run-1/submission.xml",
|
|
193
|
+
inputs=_inputs(),
|
|
194
|
+
callback_url="https://example.com/callback",
|
|
195
|
+
execution_bundle_uri="gs://bucket/run-1/",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
assert isinstance(envelope, SchematronInputEnvelope)
|
|
199
|
+
assert envelope.validator.type == ValidatorType.SCHEMATRON
|
|
200
|
+
file_item = envelope.input_files[0]
|
|
201
|
+
assert file_item.name == "submission.xml"
|
|
202
|
+
assert file_item.mime_type == SupportedMimeType.APPLICATION_XML
|
|
203
|
+
assert file_item.role == "primary-model"
|
|
204
|
+
assert envelope.inputs.schematron_text == SCH_SOURCE
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Unit tests for the canonical SVRL parser (ADR-2026-07-01, D3/D10).
|
|
2
|
+
|
|
3
|
+
``validibot_shared.schematron.svrl`` is the pure SVRL → findings/summary
|
|
4
|
+
parser both consumers rely on — the validator backend container (parsing
|
|
5
|
+
Saxon's SVRL into ``SchematronOutputs``) and the Django app (fixture and
|
|
6
|
+
round-trip tests). These tests feed it canned SVRL documents and pin the
|
|
7
|
+
D3/D10 contract:
|
|
8
|
+
|
|
9
|
+
- ``svrl:failed-assert`` AND ``svrl:successful-report`` are active findings
|
|
10
|
+
handled identically — element type never drives severity.
|
|
11
|
+
- Severity resolves ``@flag`` → ``@role`` → fail-closed ERROR, so nothing
|
|
12
|
+
publisher-authored is silently downgraded.
|
|
13
|
+
- ``svrl:fired-rule`` counts rules/contexts evaluated, never assertions.
|
|
14
|
+
- The ``finding_rule_ids_by_severity`` map is the pinned CEL contract
|
|
15
|
+
({rule_id: severity}, most severe wins).
|
|
16
|
+
- Truncation keeps ERROR findings first and is always explicit, while the
|
|
17
|
+
aggregate counts still reflect the FULL totals.
|
|
18
|
+
|
|
19
|
+
Different Schematron compilers emit slightly different SVRL dialects, so the
|
|
20
|
+
parser must be defensive — the un-namespaced-SVRL test guards that.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
from validibot_shared.schematron.svrl import (
|
|
28
|
+
SEVERITY_ERROR,
|
|
29
|
+
SEVERITY_INFO,
|
|
30
|
+
SEVERITY_WARNING,
|
|
31
|
+
SvrlParseError,
|
|
32
|
+
parse_svrl,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Named test values (avoid magic literals in assertions).
|
|
36
|
+
TRUNCATION_CAP = 2
|
|
37
|
+
TOTAL_FINDINGS_FOR_TRUNCATION = 5
|
|
38
|
+
EXPECTED_SUPPRESSED = TOTAL_FINDINGS_FOR_TRUNCATION - TRUNCATION_CAP
|
|
39
|
+
EXPECTED_FIRED_RULES = 2
|
|
40
|
+
ERRORS_IN_TRUNCATION_BODY = 2
|
|
41
|
+
WARNINGS_IN_TRUNCATION_BODY = 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _svrl(body: str) -> str:
|
|
45
|
+
"""Wrap finding elements in a minimal schematron-output document."""
|
|
46
|
+
return (
|
|
47
|
+
'<svrl:schematron-output xmlns:svrl="http://purl.oclc.org/dsdl/svrl">'
|
|
48
|
+
f"{body}"
|
|
49
|
+
"</svrl:schematron-output>"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _failed_assert(
|
|
54
|
+
*,
|
|
55
|
+
rule_id: str = "VB-X",
|
|
56
|
+
flag: str = "",
|
|
57
|
+
role: str = "",
|
|
58
|
+
location: str = "/Invoice",
|
|
59
|
+
text: str = "Something failed.",
|
|
60
|
+
) -> str:
|
|
61
|
+
attrs = f' id="{rule_id}" location="{location}"'
|
|
62
|
+
if flag:
|
|
63
|
+
attrs += f' flag="{flag}"'
|
|
64
|
+
if role:
|
|
65
|
+
attrs += f' role="{role}"'
|
|
66
|
+
return (
|
|
67
|
+
f"<svrl:failed-assert{attrs}><svrl:text>{text}</svrl:text></svrl:failed-assert>"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── Severity resolution: @flag → @role → fail-closed ERROR ──────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_failed_assert_flag_fatal_maps_to_error():
|
|
75
|
+
"""flag="fatal" resolves to ERROR and id/location survive the mapping.
|
|
76
|
+
|
|
77
|
+
The rule id and location are the whole value proposition (D10): they make
|
|
78
|
+
findings actionable and cross-referenceable against the published rules.
|
|
79
|
+
"""
|
|
80
|
+
summary = parse_svrl(
|
|
81
|
+
_svrl(
|
|
82
|
+
_failed_assert(
|
|
83
|
+
rule_id="VB-CO-15",
|
|
84
|
+
flag="fatal",
|
|
85
|
+
location="/Invoice/LegalMonetaryTotal",
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert summary.error_count == 1
|
|
91
|
+
assert not summary.passed
|
|
92
|
+
finding = summary.findings[0]
|
|
93
|
+
assert finding.rule_id == "VB-CO-15"
|
|
94
|
+
assert finding.severity == SEVERITY_ERROR
|
|
95
|
+
assert finding.location == "/Invoice/LegalMonetaryTotal"
|
|
96
|
+
assert finding.flag == "fatal"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_successful_report_is_an_active_finding_handled_identically():
|
|
100
|
+
"""A svrl:successful-report with flag="fatal" is an ERROR finding.
|
|
101
|
+
|
|
102
|
+
This is the D3 rule the naive implementation gets wrong: a ``<report>``
|
|
103
|
+
can carry a publisher-authored *error*, so the element type must never
|
|
104
|
+
drive severity — only @flag/@role do.
|
|
105
|
+
"""
|
|
106
|
+
summary = parse_svrl(
|
|
107
|
+
_svrl(
|
|
108
|
+
'<svrl:successful-report id="VB-R-01" flag="fatal" location="/a">'
|
|
109
|
+
"<svrl:text>Reported error.</svrl:text>"
|
|
110
|
+
"</svrl:successful-report>",
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
assert summary.error_count == 1
|
|
115
|
+
assert summary.findings[0].severity == SEVERITY_ERROR
|
|
116
|
+
assert summary.findings[0].element == "successful-report"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_role_is_the_fallback_when_flag_is_absent():
|
|
120
|
+
"""@role maps warning→WARNING and information→INFO when @flag is missing.
|
|
121
|
+
|
|
122
|
+
The official packs use both spellings families (warn/warning,
|
|
123
|
+
info/information); the resolver accepts them all.
|
|
124
|
+
"""
|
|
125
|
+
summary = parse_svrl(
|
|
126
|
+
_svrl(
|
|
127
|
+
_failed_assert(rule_id="VB-W", role="warning")
|
|
128
|
+
+ _failed_assert(rule_id="VB-I", role="information"),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
by_id = {f.rule_id: f for f in summary.findings}
|
|
133
|
+
assert by_id["VB-W"].severity == SEVERITY_WARNING
|
|
134
|
+
assert by_id["VB-I"].severity == SEVERITY_INFO
|
|
135
|
+
assert summary.warning_count == 1
|
|
136
|
+
assert summary.info_count == 1
|
|
137
|
+
# Warnings/info alone never fail a Schematron step (D3).
|
|
138
|
+
assert summary.passed
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_flag_wins_over_role():
|
|
142
|
+
"""When both attributes exist, @flag is authoritative (D3 resolution order)."""
|
|
143
|
+
summary = parse_svrl(
|
|
144
|
+
_svrl(_failed_assert(rule_id="VB-B", flag="fatal", role="warning")),
|
|
145
|
+
)
|
|
146
|
+
assert summary.findings[0].severity == SEVERITY_ERROR
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_fail_closed_to_error_when_neither_flag_nor_role_present():
|
|
150
|
+
"""A finding with no severity attributes fail-closes to ERROR.
|
|
151
|
+
|
|
152
|
+
Fail-open (defaulting to INFO) would silently downgrade publisher-authored
|
|
153
|
+
rules whose pack relies on a default phase severity — the exact bug class
|
|
154
|
+
D3 forbids.
|
|
155
|
+
"""
|
|
156
|
+
summary = parse_svrl(_svrl(_failed_assert(rule_id="VB-N")))
|
|
157
|
+
assert summary.findings[0].severity == SEVERITY_ERROR
|
|
158
|
+
assert summary.error_count == 1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── Message text extraction ──────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_message_text_collapses_whitespace_and_inline_markup():
|
|
165
|
+
"""svrl:text content survives inline markup and layout whitespace.
|
|
166
|
+
|
|
167
|
+
Publisher messages contain inline elements (emph/span) and authored
|
|
168
|
+
newlines; the human-readable message must come out flat and readable.
|
|
169
|
+
"""
|
|
170
|
+
summary = parse_svrl(
|
|
171
|
+
_svrl(
|
|
172
|
+
'<svrl:failed-assert id="VB-T" flag="fatal" location="/a">'
|
|
173
|
+
"<svrl:text>Total \n must <emph>equal</emph>\n sum.</svrl:text>"
|
|
174
|
+
"</svrl:failed-assert>",
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
assert summary.findings[0].message == "Total must equal sum."
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── fired-rule counting ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_fired_rule_count_counts_rules_not_assertions():
|
|
184
|
+
"""svrl:fired-rule elements count evaluated rules/contexts — nothing else.
|
|
185
|
+
|
|
186
|
+
The review explicitly renamed this signal away from "assertion count":
|
|
187
|
+
a fired rule is a context the engine evaluated, not an assertion that
|
|
188
|
+
failed. Two fired rules + one failed assert must yield 2 / 1.
|
|
189
|
+
"""
|
|
190
|
+
summary = parse_svrl(
|
|
191
|
+
_svrl(
|
|
192
|
+
'<svrl:fired-rule context="/Invoice"/>'
|
|
193
|
+
'<svrl:fired-rule context="/Invoice/Total"/>'
|
|
194
|
+
+ _failed_assert(rule_id="VB-1", flag="fatal"),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
assert summary.fired_rule_count == EXPECTED_FIRED_RULES
|
|
198
|
+
assert summary.error_count == 1
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── The finding_rule_ids_by_severity map (CEL contract) ─────────────────────
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_rule_id_map_shape_and_most_severe_wins():
|
|
205
|
+
"""The map is {rule_id: severity} and the most severe occurrence wins.
|
|
206
|
+
|
|
207
|
+
This is the pinned D2 CEL contract: key membership tests
|
|
208
|
+
(``"VB-D" in o.finding_rule_ids_by_severity``) and severity-aware gates
|
|
209
|
+
both depend on this exact shape. A rule firing at WARNING in one context
|
|
210
|
+
and ERROR in another must surface as ERROR.
|
|
211
|
+
"""
|
|
212
|
+
summary = parse_svrl(
|
|
213
|
+
_svrl(
|
|
214
|
+
_failed_assert(rule_id="VB-D", role="warning")
|
|
215
|
+
+ _failed_assert(rule_id="VB-D", flag="fatal")
|
|
216
|
+
+ _failed_assert(rule_id="VB-W", role="warning"),
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
assert summary.finding_rule_ids_by_severity == {
|
|
220
|
+
"VB-D": SEVERITY_ERROR,
|
|
221
|
+
"VB-W": SEVERITY_WARNING,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ── Truncation (D10: capped, never silent) ───────────────────────────────────
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_truncation_keeps_errors_first_and_counts_stay_full():
|
|
229
|
+
"""Capping keeps ERROR findings first and records the suppressed count.
|
|
230
|
+
|
|
231
|
+
Two invariants: (1) the kept findings are ordered ERROR → WARNING → INFO
|
|
232
|
+
so the most actionable rows survive; (2) the aggregate counts reflect the
|
|
233
|
+
FULL document, so "clean-ish" and "thousands of errors, capped" can never
|
|
234
|
+
look the same to a CEL gate.
|
|
235
|
+
"""
|
|
236
|
+
body = (
|
|
237
|
+
_failed_assert(rule_id="VB-I1", role="info")
|
|
238
|
+
+ _failed_assert(rule_id="VB-W1", role="warning")
|
|
239
|
+
+ _failed_assert(rule_id="VB-E1", flag="fatal")
|
|
240
|
+
+ _failed_assert(rule_id="VB-E2", flag="error")
|
|
241
|
+
+ _failed_assert(rule_id="VB-W2", role="warning")
|
|
242
|
+
)
|
|
243
|
+
summary = parse_svrl(_svrl(body), max_findings=TRUNCATION_CAP)
|
|
244
|
+
|
|
245
|
+
assert summary.findings_truncated
|
|
246
|
+
assert summary.findings_suppressed_count == EXPECTED_SUPPRESSED
|
|
247
|
+
assert len(summary.findings) == TRUNCATION_CAP
|
|
248
|
+
# ERROR findings survive the cap ahead of warnings/info.
|
|
249
|
+
assert {f.rule_id for f in summary.findings} == {"VB-E1", "VB-E2"}
|
|
250
|
+
# Aggregates are computed pre-cap.
|
|
251
|
+
assert summary.error_count == ERRORS_IN_TRUNCATION_BODY
|
|
252
|
+
assert summary.warning_count == WARNINGS_IN_TRUNCATION_BODY
|
|
253
|
+
assert summary.info_count == 1
|
|
254
|
+
assert len(summary.finding_rule_ids_by_severity) == TOTAL_FINDINGS_FOR_TRUNCATION
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Dialect tolerance + input guards ─────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_unnamespaced_svrl_is_tolerated():
|
|
261
|
+
"""SVRL without the namespace still parses (compiler-dialect defensiveness).
|
|
262
|
+
|
|
263
|
+
Some Schematron toolchains emit un-namespaced SVRL; the parser matches on
|
|
264
|
+
local names so those reports still map to findings.
|
|
265
|
+
"""
|
|
266
|
+
summary = parse_svrl(
|
|
267
|
+
"<schematron-output>"
|
|
268
|
+
'<fired-rule context="/a"/>'
|
|
269
|
+
'<failed-assert id="VB-U" flag="fatal" location="/a">'
|
|
270
|
+
"<text>Unnamespaced.</text>"
|
|
271
|
+
"</failed-assert>"
|
|
272
|
+
"</schematron-output>",
|
|
273
|
+
)
|
|
274
|
+
assert summary.fired_rule_count == 1
|
|
275
|
+
assert summary.findings[0].rule_id == "VB-U"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_empty_and_malformed_svrl_raise():
|
|
279
|
+
"""Empty or non-XML input raises SvrlParseError rather than passing.
|
|
280
|
+
|
|
281
|
+
Silently returning an empty summary for garbage input would read as
|
|
282
|
+
"zero findings" — i.e. a pass — which is exactly the fail-open behaviour
|
|
283
|
+
the D9 taxonomy exists to prevent.
|
|
284
|
+
"""
|
|
285
|
+
with pytest.raises(SvrlParseError):
|
|
286
|
+
parse_svrl("")
|
|
287
|
+
with pytest.raises(SvrlParseError):
|
|
288
|
+
parse_svrl("this is not XML <")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Schematron Advanced validator envelopes and models."""
|
|
2
|
+
|
|
3
|
+
from validibot_shared.schematron.envelopes import (
|
|
4
|
+
ENGINE_ERROR_BACKEND_UNAVAILABLE,
|
|
5
|
+
ENGINE_ERROR_RULES_INVALID,
|
|
6
|
+
ENGINE_STATUS_ERROR,
|
|
7
|
+
ENGINE_STATUS_OK,
|
|
8
|
+
ENGINE_STATUS_TIMEOUT,
|
|
9
|
+
QUERY_BINDING_XSLT1,
|
|
10
|
+
QUERY_BINDING_XSLT2,
|
|
11
|
+
SchematronFinding,
|
|
12
|
+
SchematronInputEnvelope,
|
|
13
|
+
SchematronInputs,
|
|
14
|
+
SchematronOutputEnvelope,
|
|
15
|
+
SchematronOutputs,
|
|
16
|
+
build_schematron_input_envelope,
|
|
17
|
+
)
|
|
18
|
+
from validibot_shared.schematron.svrl import (
|
|
19
|
+
SEVERITY_ERROR,
|
|
20
|
+
SEVERITY_INFO,
|
|
21
|
+
SEVERITY_WARNING,
|
|
22
|
+
SvrlFinding,
|
|
23
|
+
SvrlParseError,
|
|
24
|
+
SvrlSummary,
|
|
25
|
+
parse_svrl,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ENGINE_ERROR_BACKEND_UNAVAILABLE",
|
|
30
|
+
"ENGINE_ERROR_RULES_INVALID",
|
|
31
|
+
"ENGINE_STATUS_ERROR",
|
|
32
|
+
"ENGINE_STATUS_OK",
|
|
33
|
+
"ENGINE_STATUS_TIMEOUT",
|
|
34
|
+
"QUERY_BINDING_XSLT1",
|
|
35
|
+
"QUERY_BINDING_XSLT2",
|
|
36
|
+
"SEVERITY_ERROR",
|
|
37
|
+
"SEVERITY_INFO",
|
|
38
|
+
"SEVERITY_WARNING",
|
|
39
|
+
"SchematronFinding",
|
|
40
|
+
"SchematronInputEnvelope",
|
|
41
|
+
"SchematronInputs",
|
|
42
|
+
"SchematronOutputEnvelope",
|
|
43
|
+
"SchematronOutputs",
|
|
44
|
+
"SvrlFinding",
|
|
45
|
+
"SvrlParseError",
|
|
46
|
+
"SvrlSummary",
|
|
47
|
+
"build_schematron_input_envelope",
|
|
48
|
+
"parse_svrl",
|
|
49
|
+
]
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic envelopes for the Schematron Advanced validator backend.
|
|
3
|
+
|
|
4
|
+
The Schematron validator is an Advanced validator — it runs in an isolated
|
|
5
|
+
container/Cloud Run Job rather than inside the Django worker. Schematron
|
|
6
|
+
rules compile to XSLT (a full programming language), so author-uploaded
|
|
7
|
+
rules are executable code and only ever run inside the sandboxed container:
|
|
8
|
+
no database, no secrets, no network egress, locked-down engine
|
|
9
|
+
(ADR-2026-07-01, decisions D4/D8).
|
|
10
|
+
|
|
11
|
+
These schemas define the contract between Django and the Schematron
|
|
12
|
+
container:
|
|
13
|
+
|
|
14
|
+
- **Input envelope**: the XML submission (as an ``InputFileItem`` URI) plus
|
|
15
|
+
the author's Schematron rules **inline as text** — exactly how SHACL
|
|
16
|
+
ships its merged shapes text. Django resolves the rules from the step's
|
|
17
|
+
``Ruleset`` before dispatch; the container compiles them (SchXslt2 →
|
|
18
|
+
XSLT, baked into the image as fixed tooling) and runs the result over the
|
|
19
|
+
submission. The D8 resource limits ride along, already clamped
|
|
20
|
+
Django-side; the container re-clamps defensively.
|
|
21
|
+
- **Output envelope**: the parsed SVRL summary — per-severity counts, the
|
|
22
|
+
``finding_rule_ids_by_severity`` map, structured findings preserving
|
|
23
|
+
native rule ids/locations — plus ``engine_status`` (D9: findings are only
|
|
24
|
+
meaningful when the engine actually ran) and provenance (the sha256 of
|
|
25
|
+
the executed rules, the detected query binding, and the engine that ran).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, Field
|
|
31
|
+
|
|
32
|
+
from validibot_shared.validations.envelopes import (
|
|
33
|
+
ExecutionContext,
|
|
34
|
+
InputFileItem,
|
|
35
|
+
SupportedMimeType,
|
|
36
|
+
ValidationInputEnvelope,
|
|
37
|
+
ValidationOutputEnvelope,
|
|
38
|
+
ValidatorInfo,
|
|
39
|
+
ValidatorType,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# ── D9 engine status: "couldn't run the rules" ≠ "the rules failed" ────────
|
|
43
|
+
# ``passed``/findings on the outputs are only meaningful when the engine
|
|
44
|
+
# status is OK. On error/timeout, Django surfaces a single reserved
|
|
45
|
+
# infrastructure finding and never synthesises rule findings.
|
|
46
|
+
ENGINE_STATUS_OK = "ok"
|
|
47
|
+
ENGINE_STATUS_ERROR = "error"
|
|
48
|
+
ENGINE_STATUS_TIMEOUT = "timeout"
|
|
49
|
+
|
|
50
|
+
# Machine hints for ``SchematronOutputs.engine_error_code`` — Django maps
|
|
51
|
+
# these to its reserved ``schematron.*`` finding codes.
|
|
52
|
+
#
|
|
53
|
+
# ``rules_invalid``: the author's uploaded Schematron failed to compile.
|
|
54
|
+
# That's a workflow-authoring problem, not a fact about the submitted
|
|
55
|
+
# document — the submitter's run still reads "the check couldn't run".
|
|
56
|
+
ENGINE_ERROR_RULES_INVALID = "rules_invalid"
|
|
57
|
+
ENGINE_ERROR_BACKEND_UNAVAILABLE = "backend_unavailable"
|
|
58
|
+
|
|
59
|
+
# Schematron query bindings (declared by the .sch root's ``queryBinding``
|
|
60
|
+
# attribute; the container detects and echoes it for provenance).
|
|
61
|
+
QUERY_BINDING_XSLT1 = "xslt1"
|
|
62
|
+
QUERY_BINDING_XSLT2 = "xslt2"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SchematronInputs(BaseModel):
|
|
66
|
+
"""Schematron run configuration resolved by Django for the container.
|
|
67
|
+
|
|
68
|
+
Everything the container needs without database access: the author's
|
|
69
|
+
rules inline (the SHACL ``shapes_text`` pattern — a ``.sch`` is a text
|
|
70
|
+
document, typically tens to hundreds of KB) and the D8 resource limits
|
|
71
|
+
(already clamped Django-side; re-clamped in the container).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
schematron_text: str = Field(
|
|
75
|
+
description=(
|
|
76
|
+
"The Schematron source (.sch) to compile and run, resolved from "
|
|
77
|
+
"the step's Ruleset by Django. The container compiles it with "
|
|
78
|
+
"the SchXslt2 transpiler baked into the image."
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
schematron_sha256: str = Field(
|
|
82
|
+
default="",
|
|
83
|
+
description=(
|
|
84
|
+
"sha256 of schematron_text, computed by Django at dispatch — "
|
|
85
|
+
"the provenance identity of the rules this run executed."
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# ── D8 resource limits (clamped Django-side; re-clamped in container) ──
|
|
90
|
+
max_input_bytes: int = Field(default=10_000_000, gt=0)
|
|
91
|
+
max_input_depth: int = Field(default=200, gt=0)
|
|
92
|
+
xslt_timeout_seconds: int = Field(default=60, gt=0)
|
|
93
|
+
max_memory_mb: int = Field(default=512, gt=0)
|
|
94
|
+
max_findings: int = Field(default=500, gt=0)
|
|
95
|
+
|
|
96
|
+
model_config = {"extra": "forbid"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SchematronFinding(BaseModel):
|
|
100
|
+
"""One active SVRL finding with the detail Django needs for D10.
|
|
101
|
+
|
|
102
|
+
Both ``svrl:failed-assert`` and ``svrl:successful-report`` entries are
|
|
103
|
+
active findings (a ``<report>`` can carry a publisher-authored error).
|
|
104
|
+
``rule_id`` is the rule's native identifier (``BR-CO-15``,
|
|
105
|
+
``PEPPOL-EN16931-R010``) and becomes ``ValidationFinding.code`` in
|
|
106
|
+
Django; ``location_xpath`` preserves the SVRL ``@location`` so the
|
|
107
|
+
finding can point at the offending element. ``flag``/``role`` carry the
|
|
108
|
+
raw SVRL attributes for provenance (severity was resolved from them via
|
|
109
|
+
the @flag → @role → fail-closed-ERROR chain, D3).
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
rule_id: str = Field(default="", description="Native rule id (@id).")
|
|
113
|
+
message: str = Field(description="Human-readable finding text.")
|
|
114
|
+
severity: str = Field(description="ERROR | WARNING | INFO (resolved).")
|
|
115
|
+
location_xpath: str = Field(
|
|
116
|
+
default="",
|
|
117
|
+
description="SVRL @location XPath into the submitted document.",
|
|
118
|
+
)
|
|
119
|
+
flag: str = Field(default="", description="Raw SVRL @flag attribute.")
|
|
120
|
+
role: str = Field(default="", description="Raw SVRL @role attribute.")
|
|
121
|
+
|
|
122
|
+
model_config = {"extra": "forbid"}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SchematronOutputs(BaseModel):
|
|
126
|
+
"""Schematron results: engine status, SVRL summary, and provenance.
|
|
127
|
+
|
|
128
|
+
The signal fields (``passed`` … ``engine``) mirror the catalog entries in
|
|
129
|
+
the Django Schematron ``ValidatorConfig`` — Django's
|
|
130
|
+
``extract_output_signals`` pulls exactly those keys for CEL assertion
|
|
131
|
+
evaluation.
|
|
132
|
+
|
|
133
|
+
D9 contract: when ``engine_status != "ok"`` the run never evaluated the
|
|
134
|
+
rules — ``passed`` is ``None`` (*unknown*, not failed), the counts are
|
|
135
|
+
meaningless, and ``finding_rule_ids_by_severity``/``findings`` stay
|
|
136
|
+
empty. Django surfaces one reserved infrastructure finding instead.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
# ── D9 engine status ──
|
|
140
|
+
engine_status: str = Field(
|
|
141
|
+
default=ENGINE_STATUS_OK,
|
|
142
|
+
description="ok | error | timeout — findings only meaningful on ok.",
|
|
143
|
+
)
|
|
144
|
+
engine_message: str = Field(
|
|
145
|
+
default="",
|
|
146
|
+
description="Human-readable engine failure detail (when not ok).",
|
|
147
|
+
)
|
|
148
|
+
engine_error_code: str = Field(
|
|
149
|
+
default="",
|
|
150
|
+
description=(
|
|
151
|
+
"Machine hint for the failure kind (e.g. rules_invalid, "
|
|
152
|
+
"backend_unavailable); maps to Django's reserved codes."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# ── o.* signals (must stay aligned with the ValidatorConfig catalog) ──
|
|
157
|
+
passed: bool | None = Field(
|
|
158
|
+
default=None,
|
|
159
|
+
description=(
|
|
160
|
+
"True iff zero ERROR-level findings; None (unknown) when the "
|
|
161
|
+
"engine could not run."
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
error_count: int = Field(default=0, ge=0)
|
|
165
|
+
warning_count: int = Field(default=0, ge=0)
|
|
166
|
+
info_count: int = Field(default=0, ge=0)
|
|
167
|
+
# svrl:fired-rule counts rules/contexts EVALUATED, not assertions that
|
|
168
|
+
# fired — never surface this as an "assertion count" (D3 SVRL note).
|
|
169
|
+
fired_rule_count: int = Field(default=0, ge=0)
|
|
170
|
+
finding_rule_ids_by_severity: dict[str, str] = Field(
|
|
171
|
+
default_factory=dict,
|
|
172
|
+
description=(
|
|
173
|
+
'Map of native rule id to resolved severity, e.g. {"BR-CO-15": '
|
|
174
|
+
'"ERROR"}. Key membership + severity gates in CEL (D2).'
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# ── Findings (volume-capped, never silently — D10) ──
|
|
179
|
+
findings: list[SchematronFinding] = Field(
|
|
180
|
+
default_factory=list,
|
|
181
|
+
description=(
|
|
182
|
+
"Active findings (failed-asserts AND successful-reports), "
|
|
183
|
+
"capped at max_findings ordered ERROR → WARNING → INFO."
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
findings_truncated: bool = Field(
|
|
187
|
+
default=False,
|
|
188
|
+
description="True when the findings list was capped.",
|
|
189
|
+
)
|
|
190
|
+
findings_suppressed_count: int = Field(
|
|
191
|
+
default=0,
|
|
192
|
+
ge=0,
|
|
193
|
+
description="How many findings the cap suppressed (counts stay full).",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# ── Provenance of the executed rules + engine (D5) ──
|
|
197
|
+
schematron_sha256: str = Field(
|
|
198
|
+
default="",
|
|
199
|
+
description="sha256 of the Schematron source that was executed.",
|
|
200
|
+
)
|
|
201
|
+
query_binding: str = Field(
|
|
202
|
+
default="",
|
|
203
|
+
description=(
|
|
204
|
+
"Query binding detected from the .sch root (xslt1/xslt2/…), "
|
|
205
|
+
"echoed for provenance."
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
engine: str = Field(
|
|
209
|
+
default="",
|
|
210
|
+
description="Engine name + version that ran, e.g. 'SaxonC-HE 12.9'.",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
execution_seconds: float = Field(default=0.0, ge=0)
|
|
214
|
+
|
|
215
|
+
model_config = {"extra": "forbid"}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class SchematronInputEnvelope(ValidationInputEnvelope):
|
|
219
|
+
"""Input envelope for Schematron validator containers."""
|
|
220
|
+
|
|
221
|
+
inputs: SchematronInputs
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SchematronOutputEnvelope(ValidationOutputEnvelope):
|
|
225
|
+
"""Output envelope from Schematron validator containers.
|
|
226
|
+
|
|
227
|
+
``outputs`` can be ``None`` for runtime-failure cases where the backend
|
|
228
|
+
crashed before producing even an engine-status summary.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
outputs: SchematronOutputs | None = None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_schematron_input_envelope(
|
|
235
|
+
*,
|
|
236
|
+
run_id: str,
|
|
237
|
+
validator,
|
|
238
|
+
org_id: str,
|
|
239
|
+
org_name: str,
|
|
240
|
+
workflow_id: str,
|
|
241
|
+
step_id: str,
|
|
242
|
+
step_name: str | None,
|
|
243
|
+
submission_uri: str,
|
|
244
|
+
inputs: SchematronInputs,
|
|
245
|
+
callback_url: str,
|
|
246
|
+
execution_bundle_uri: str,
|
|
247
|
+
callback_id: str | None = None,
|
|
248
|
+
skip_callback: bool = False,
|
|
249
|
+
) -> SchematronInputEnvelope:
|
|
250
|
+
"""Build a ``SchematronInputEnvelope`` from Django validation data.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
run_id: ValidationRun ID.
|
|
254
|
+
validator: Validator-like object (id/validation_type/version attrs).
|
|
255
|
+
org_id / org_name: Organization identity.
|
|
256
|
+
workflow_id / step_id / step_name: Workflow context.
|
|
257
|
+
submission_uri: Storage URI for the XML submission (gs:// or file://).
|
|
258
|
+
inputs: Fully-resolved ``SchematronInputs`` (inline rules + limits).
|
|
259
|
+
callback_url: URL the container POSTs to on completion.
|
|
260
|
+
execution_bundle_uri: Base URI for this run's bundle.
|
|
261
|
+
callback_id: Idempotency key echoed back in the callback.
|
|
262
|
+
skip_callback: True for synchronous (Docker) execution.
|
|
263
|
+
"""
|
|
264
|
+
input_files = [
|
|
265
|
+
InputFileItem(
|
|
266
|
+
name="submission.xml",
|
|
267
|
+
mime_type=SupportedMimeType.APPLICATION_XML,
|
|
268
|
+
role="primary-model",
|
|
269
|
+
uri=submission_uri,
|
|
270
|
+
),
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
context = ExecutionContext(
|
|
274
|
+
callback_id=callback_id,
|
|
275
|
+
callback_url=callback_url,
|
|
276
|
+
execution_bundle_uri=execution_bundle_uri,
|
|
277
|
+
skip_callback=skip_callback,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return SchematronInputEnvelope(
|
|
281
|
+
run_id=run_id,
|
|
282
|
+
validator=ValidatorInfo(
|
|
283
|
+
id=str(validator.id),
|
|
284
|
+
type=ValidatorType(validator.validation_type),
|
|
285
|
+
version=str(getattr(validator, "version", "1")),
|
|
286
|
+
),
|
|
287
|
+
org={"id": org_id, "name": org_name},
|
|
288
|
+
workflow={
|
|
289
|
+
"id": workflow_id,
|
|
290
|
+
"step_id": step_id,
|
|
291
|
+
"step_name": step_name,
|
|
292
|
+
},
|
|
293
|
+
input_files=input_files,
|
|
294
|
+
inputs=inputs,
|
|
295
|
+
context=context,
|
|
296
|
+
)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""SVRL report parsing — findings + signal summary (ADR-2026-07-01, D3/D10).
|
|
2
|
+
|
|
3
|
+
SVRL (Schematron Validation Report Language, ISO/IEC 19757-3) is the XML
|
|
4
|
+
report a Schematron run produces. This module parses an SVRL document into a
|
|
5
|
+
:class:`SvrlSummary`: the individual findings plus the aggregate counts and
|
|
6
|
+
the ``finding_rule_ids_by_severity`` map that feed the validator's ``o.*``
|
|
7
|
+
signal surface.
|
|
8
|
+
|
|
9
|
+
Design rules carried from the ADR:
|
|
10
|
+
|
|
11
|
+
- **Both ``svrl:failed-assert`` and ``svrl:successful-report`` are active
|
|
12
|
+
findings**, handled identically. A ``<report>`` can carry a
|
|
13
|
+
publisher-authored error, so the element type must never drive severity.
|
|
14
|
+
- **Severity resolves ``@flag`` → ``@role`` → fail-closed ``ERROR``.**
|
|
15
|
+
``fatal``/``error`` → ERROR, ``warning``/``warn`` → WARNING,
|
|
16
|
+
``info``/``information`` → INFO. A finding with neither attribute is
|
|
17
|
+
fail-closed to ERROR and logged for pack-curation review — nothing
|
|
18
|
+
publisher-authored is silently downgraded.
|
|
19
|
+
- **``svrl:fired-rule`` counts rules/contexts evaluated**, *not* assertions
|
|
20
|
+
that fired — surfaced as ``fired_rule_count``, never an "assertion count".
|
|
21
|
+
- **Findings are volume-capped, never silently** (D10): when a document blows
|
|
22
|
+
``max_findings`` the parser keeps the first N ordered ERROR → WARNING →
|
|
23
|
+
INFO (document order within a severity) and reports how many were
|
|
24
|
+
suppressed. The caller renders the explicit truncation finding.
|
|
25
|
+
|
|
26
|
+
This is the CANONICAL parser for the whole pipeline: the validator backend
|
|
27
|
+
container parses Saxon's SVRL with it to build ``SchematronOutputs``, and the
|
|
28
|
+
Django app re-exports it (``validibot.validations.validators.schematron.svrl``)
|
|
29
|
+
for its fixture/round-trip tests. It lives beside the envelope models because
|
|
30
|
+
it defines the same protocol boundary — SVRL in, the D2 signal contract out.
|
|
31
|
+
Deliberately framework-free: plain-string severities, ``defusedxml``-only
|
|
32
|
+
parsing, no Django.
|
|
33
|
+
|
|
34
|
+
Different Schematron compilers emit slightly different SVRL dialects; parsing
|
|
35
|
+
is defensive (namespace-tolerant, attribute-tolerant) and covered by the
|
|
36
|
+
unit tests in this repo.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import logging
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
|
|
44
|
+
from defusedxml import ElementTree as SafeET
|
|
45
|
+
from defusedxml.common import (
|
|
46
|
+
DTDForbidden,
|
|
47
|
+
EntitiesForbidden,
|
|
48
|
+
ExternalReferenceForbidden,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
# The SVRL namespace from ISO/IEC 19757-3. Some tools emit un-namespaced
|
|
54
|
+
# SVRL; matching is namespace-tolerant (see _local_name).
|
|
55
|
+
SVRL_NS = "http://purl.oclc.org/dsdl/svrl"
|
|
56
|
+
|
|
57
|
+
# Plain-string severities (shared-envelope compatible; Django maps them to
|
|
58
|
+
# its own Severity enum in validator.py).
|
|
59
|
+
SEVERITY_ERROR = "ERROR"
|
|
60
|
+
SEVERITY_WARNING = "WARNING"
|
|
61
|
+
SEVERITY_INFO = "INFO"
|
|
62
|
+
|
|
63
|
+
# Rank used both for the "most severe wins" duplicate-rule-id rule and the
|
|
64
|
+
# ERROR → WARNING → INFO truncation ordering (D10).
|
|
65
|
+
_SEVERITY_RANK = {SEVERITY_ERROR: 0, SEVERITY_WARNING: 1, SEVERITY_INFO: 2}
|
|
66
|
+
|
|
67
|
+
# @flag / @role values → severity. Lowercased before lookup.
|
|
68
|
+
_SEVERITY_FROM_ATTR = {
|
|
69
|
+
"fatal": SEVERITY_ERROR,
|
|
70
|
+
"error": SEVERITY_ERROR,
|
|
71
|
+
"warning": SEVERITY_WARNING,
|
|
72
|
+
"warn": SEVERITY_WARNING,
|
|
73
|
+
"info": SEVERITY_INFO,
|
|
74
|
+
"information": SEVERITY_INFO,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Default findings cap — mirrors SCHEMATRON_MAX_FINDINGS in security.py
|
|
78
|
+
# (kept as a literal here so this module stays import-light and pure).
|
|
79
|
+
DEFAULT_MAX_FINDINGS = 500
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SvrlParseError(ValueError):
|
|
83
|
+
"""Raised when SVRL content cannot be parsed as XML."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class SvrlFinding:
|
|
88
|
+
"""One active SVRL finding (a failed assert or a successful report).
|
|
89
|
+
|
|
90
|
+
``rule_id`` is the publisher's native identifier (``BR-CO-15``,
|
|
91
|
+
``PEPPOL-EN16931-R010``, or a fixture's ``VB-*`` id) taken from the
|
|
92
|
+
element's ``@id``; it becomes ``ValidationFinding.code`` downstream (D10).
|
|
93
|
+
``flag`` and ``role`` carry the raw attributes for provenance.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
rule_id: str
|
|
97
|
+
message: str
|
|
98
|
+
severity: str # SEVERITY_ERROR | SEVERITY_WARNING | SEVERITY_INFO
|
|
99
|
+
location: str = "" # SVRL @location XPath into the submitted document
|
|
100
|
+
flag: str = ""
|
|
101
|
+
role: str = ""
|
|
102
|
+
element: str = "" # "failed-assert" | "successful-report"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class SvrlSummary:
|
|
107
|
+
"""Parsed SVRL: findings plus the aggregate ``o.*`` signal values.
|
|
108
|
+
|
|
109
|
+
``finding_rule_ids_by_severity`` is the D2-pinned CEL map contract:
|
|
110
|
+
``{rule_id: severity_string}`` so ``"BR-CO-15" in o.finding_rule_ids_by_severity``
|
|
111
|
+
is key membership and ``o.finding_rule_ids_by_severity["BR-CO-15"] == "ERROR"``
|
|
112
|
+
is a severity-aware gate. When one rule id fires at several severities,
|
|
113
|
+
the most severe wins.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
findings: list[SvrlFinding] = field(default_factory=list)
|
|
117
|
+
error_count: int = 0
|
|
118
|
+
warning_count: int = 0
|
|
119
|
+
info_count: int = 0
|
|
120
|
+
fired_rule_count: int = 0
|
|
121
|
+
finding_rule_ids_by_severity: dict[str, str] = field(default_factory=dict)
|
|
122
|
+
findings_truncated: bool = False
|
|
123
|
+
findings_suppressed_count: int = 0
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def passed(self) -> bool:
|
|
127
|
+
"""A Schematron run passes iff there are no ERROR-level findings (D3)."""
|
|
128
|
+
return self.error_count == 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_svrl(
|
|
132
|
+
svrl_content: str | bytes,
|
|
133
|
+
*,
|
|
134
|
+
max_findings: int = DEFAULT_MAX_FINDINGS,
|
|
135
|
+
) -> SvrlSummary:
|
|
136
|
+
"""Parse an SVRL document into findings + the signal summary.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
svrl_content: The SVRL report as produced by a Schematron run
|
|
140
|
+
(lxml.isoschematron for XSLT-1.0 fixtures; Saxon in production).
|
|
141
|
+
max_findings: Volume cap (D8/D10). The kept findings are the first N
|
|
142
|
+
ordered ERROR → WARNING → INFO; the summary records how many were
|
|
143
|
+
suppressed so the caller can surface an explicit truncation
|
|
144
|
+
finding — truncation is never silent.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
SvrlParseError: If the content is empty or not well-formed XML.
|
|
148
|
+
SVRL comes from our own engine, but defusedxml is used anyway as
|
|
149
|
+
defence in depth.
|
|
150
|
+
"""
|
|
151
|
+
if not svrl_content or not str(svrl_content).strip():
|
|
152
|
+
raise SvrlParseError("Empty SVRL content.")
|
|
153
|
+
|
|
154
|
+
raw = (
|
|
155
|
+
svrl_content.encode("utf-8") if isinstance(svrl_content, str) else svrl_content
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
root = SafeET.fromstring(raw, forbid_dtd=True)
|
|
159
|
+
except (EntitiesForbidden, ExternalReferenceForbidden, DTDForbidden) as exc:
|
|
160
|
+
raise SvrlParseError(
|
|
161
|
+
"SVRL contains forbidden constructs (entities/DTD).",
|
|
162
|
+
) from exc
|
|
163
|
+
except SafeET.ParseError as exc:
|
|
164
|
+
raise SvrlParseError(f"Invalid SVRL XML: {exc}") from exc
|
|
165
|
+
|
|
166
|
+
summary = SvrlSummary()
|
|
167
|
+
all_findings: list[SvrlFinding] = []
|
|
168
|
+
|
|
169
|
+
for element in root.iter():
|
|
170
|
+
name = _local_name(element.tag)
|
|
171
|
+
if name == "fired-rule":
|
|
172
|
+
summary.fired_rule_count += 1
|
|
173
|
+
elif name in ("failed-assert", "successful-report"):
|
|
174
|
+
all_findings.append(_parse_finding(element, name))
|
|
175
|
+
|
|
176
|
+
# Aggregate counts + the rule-id map over ALL findings (pre-cap): the
|
|
177
|
+
# signal surface must reflect the true totals even when the persisted
|
|
178
|
+
# findings list is truncated.
|
|
179
|
+
for finding in all_findings:
|
|
180
|
+
if finding.severity == SEVERITY_ERROR:
|
|
181
|
+
summary.error_count += 1
|
|
182
|
+
elif finding.severity == SEVERITY_WARNING:
|
|
183
|
+
summary.warning_count += 1
|
|
184
|
+
else:
|
|
185
|
+
summary.info_count += 1
|
|
186
|
+
|
|
187
|
+
if finding.rule_id:
|
|
188
|
+
existing = summary.finding_rule_ids_by_severity.get(finding.rule_id)
|
|
189
|
+
if (
|
|
190
|
+
existing is None
|
|
191
|
+
or _SEVERITY_RANK[finding.severity] < _SEVERITY_RANK[existing]
|
|
192
|
+
):
|
|
193
|
+
summary.finding_rule_ids_by_severity[finding.rule_id] = finding.severity
|
|
194
|
+
else:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"SVRL %s finding has no @id; kept without a rule id (message=%r)",
|
|
197
|
+
finding.element,
|
|
198
|
+
finding.message[:120],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Volume cap (D10): keep ERROR findings first, then WARNING, then INFO,
|
|
202
|
+
# preserving document order within each severity band.
|
|
203
|
+
if max_findings > 0 and len(all_findings) > max_findings:
|
|
204
|
+
ordered = sorted(
|
|
205
|
+
all_findings,
|
|
206
|
+
key=lambda f: _SEVERITY_RANK[f.severity],
|
|
207
|
+
)
|
|
208
|
+
summary.findings = ordered[:max_findings]
|
|
209
|
+
summary.findings_truncated = True
|
|
210
|
+
summary.findings_suppressed_count = len(all_findings) - max_findings
|
|
211
|
+
logger.warning(
|
|
212
|
+
"SVRL findings truncated: kept %d of %d (cap=%d)",
|
|
213
|
+
max_findings,
|
|
214
|
+
len(all_findings),
|
|
215
|
+
max_findings,
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
summary.findings = all_findings
|
|
219
|
+
|
|
220
|
+
return summary
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_finding(element, element_name: str) -> SvrlFinding:
|
|
224
|
+
"""Map one failed-assert / successful-report element to a finding.
|
|
225
|
+
|
|
226
|
+
Severity resolution is the D3 chain: ``@flag`` first, then ``@role``,
|
|
227
|
+
then fail-closed to ERROR with a log line for pack-curation review.
|
|
228
|
+
"""
|
|
229
|
+
flag = (element.get("flag") or "").strip()
|
|
230
|
+
role = (element.get("role") or "").strip()
|
|
231
|
+
|
|
232
|
+
severity = _SEVERITY_FROM_ATTR.get(flag.lower())
|
|
233
|
+
if severity is None:
|
|
234
|
+
severity = _SEVERITY_FROM_ATTR.get(role.lower())
|
|
235
|
+
if severity is None:
|
|
236
|
+
severity = SEVERITY_ERROR
|
|
237
|
+
rule_id_for_log = (element.get("id") or "").strip()
|
|
238
|
+
logger.warning(
|
|
239
|
+
"SVRL %s (id=%r) has no recognisable @flag/@role "
|
|
240
|
+
"(flag=%r, role=%r); fail-closed to ERROR",
|
|
241
|
+
element_name,
|
|
242
|
+
rule_id_for_log,
|
|
243
|
+
flag,
|
|
244
|
+
role,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return SvrlFinding(
|
|
248
|
+
rule_id=(element.get("id") or "").strip(),
|
|
249
|
+
message=_finding_text(element),
|
|
250
|
+
severity=severity,
|
|
251
|
+
location=(element.get("location") or "").strip(),
|
|
252
|
+
flag=flag,
|
|
253
|
+
role=role,
|
|
254
|
+
element=element_name,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _finding_text(element) -> str:
|
|
259
|
+
"""Extract the human-readable message from a finding's svrl:text child.
|
|
260
|
+
|
|
261
|
+
Joins all descendant text of every ``svrl:text`` child (publisher
|
|
262
|
+
messages may contain inline markup like ``<emph>``), collapsing internal
|
|
263
|
+
whitespace — SVRL text is authored with layout newlines that mean nothing.
|
|
264
|
+
"""
|
|
265
|
+
parts: list[str] = []
|
|
266
|
+
for child in element:
|
|
267
|
+
if _local_name(child.tag) == "text":
|
|
268
|
+
parts.append(" ".join("".join(child.itertext()).split()))
|
|
269
|
+
return " ".join(p for p in parts if p).strip()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _local_name(tag: object) -> str:
|
|
273
|
+
"""Strip any XML namespace from a tag name (namespace-tolerant matching)."""
|
|
274
|
+
text = str(tag)
|
|
275
|
+
if text.startswith("{"):
|
|
276
|
+
return text.split("}", 1)[1]
|
|
277
|
+
return text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/energyplus/envelopes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{validibot_shared-0.10.0 → validibot_shared-0.12.0}/validibot_shared/validations/__init__.py
RENAMED
|
File without changes
|