validibot-shared 0.12.0__tar.gz → 0.13.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.
Files changed (40) hide show
  1. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/PKG-INFO +45 -5
  2. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/README.md +43 -3
  3. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/pyproject.toml +2 -2
  4. validibot_shared-0.13.0/tests/test_artifacts.py +101 -0
  5. validibot_shared-0.13.0/tests/test_canonicalization.py +100 -0
  6. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_validations_envelopes.py +27 -0
  7. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/__init__.py +10 -0
  8. validibot_shared-0.13.0/validibot_shared/_vendor/__init__.py +1 -0
  9. validibot_shared-0.13.0/validibot_shared/_vendor/jcs.py +191 -0
  10. validibot_shared-0.13.0/validibot_shared/canonicalization.py +57 -0
  11. validibot_shared-0.13.0/validibot_shared/validations/artifacts.py +155 -0
  12. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/validations/envelopes.py +16 -0
  13. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/.gitignore +0 -0
  14. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/LICENSE +0 -0
  15. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/NOTICE +0 -0
  16. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/__init__.py +0 -0
  17. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_energyplus_envelopes.py +0 -0
  18. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_energyplus_models.py +0 -0
  19. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_evidence_manifest.py +0 -0
  20. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_fmu_envelopes.py +0 -0
  21. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_fmu_models.py +0 -0
  22. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_package_init.py +0 -0
  23. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_schematron_envelopes.py +0 -0
  24. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_schematron_svrl.py +0 -0
  25. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/tests/test_shacl_envelopes.py +0 -0
  26. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/energyplus/__init__.py +0 -0
  27. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/energyplus/envelopes.py +0 -0
  28. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/energyplus/models.py +0 -0
  29. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/evidence/__init__.py +0 -0
  30. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/evidence/manifest.py +0 -0
  31. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/fmu/__init__.py +0 -0
  32. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/fmu/envelopes.py +0 -0
  33. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/fmu/models.py +0 -0
  34. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/py.typed +0 -0
  35. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/schematron/__init__.py +0 -0
  36. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/schematron/envelopes.py +0 -0
  37. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/schematron/svrl.py +0 -0
  38. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/shacl/__init__.py +0 -0
  39. {validibot_shared-0.12.0 → validibot_shared-0.13.0}/validibot_shared/shacl/envelopes.py +0 -0
  40. {validibot_shared-0.12.0 → validibot_shared-0.13.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.12.0
3
+ Version: 0.13.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
@@ -28,7 +28,7 @@ Requires-Dist: defusedxml<0.8,>=0.7.1
28
28
  Requires-Dist: pydantic<3.0,>=2.13
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest==9.1.1; extra == 'dev'
31
- Requires-Dist: ruff==0.15.20; extra == 'dev'
31
+ Requires-Dist: ruff==0.15.21; extra == 'dev'
32
32
  Description-Content-Type: text/markdown
33
33
 
34
34
  <div align="center">
@@ -167,11 +167,46 @@ Supporting models include:
167
167
  | Class | Purpose |
168
168
  |-------|---------|
169
169
  | `InputFileItem` | File reference with URI, MIME type, and role |
170
+ | `ResourceFileItem` | Managed auxiliary file reference with URI and resource type |
170
171
  | `ValidatorInfo` | Validator identification (ID, type, version) |
171
172
  | `ExecutionContext` | Callback URL, execution bundle URI, timeout |
172
173
  | `ValidationMessage` | Individual finding (error, warning, info) |
173
174
  | `ValidationMetric` | Named numeric metric with optional unit |
174
175
  | `ValidationArtifact` | Output file reference (reports, logs, etc.) |
176
+ | `ArtifactRef` | Indexed run artifact reference for workflow/evidence control planes |
177
+ | `FilePortContract` | Shared file-port vocabulary for declared validator file inputs/outputs |
178
+
179
+ ### Designing File Inputs
180
+
181
+ The shared envelope exposes file inputs as `input_files` and `resource_files`.
182
+ Validator authors should still design those files as **declared ports** in the
183
+ core platform.
184
+
185
+ A file port answers:
186
+
187
+ - what the file means to the validator (`primary_model`, `weather_file`,
188
+ `data_graph`, `xml_document`, `schema_file`, `fmu_model`);
189
+ - how many files are valid (`1..1`, `0..1`, or a future collection);
190
+ - which envelope channel it renders into (`input_files` or `resource_files`);
191
+ - which backend role/type it uses (`primary-model`, `weather`, `fmu`,
192
+ `data-graph`);
193
+ - which formats and MIME types are accepted;
194
+ - whether the source may be a submitted file, workflow resource, upstream
195
+ artifact, or signal containing an artifact reference.
196
+
197
+ Keep small configuration in typed `inputs`. Use file/resource/artifact ports
198
+ for bytes. For example, EnergyPlus timestep settings belong in
199
+ `EnergyPlusInputs`; the IDF/epJSON model and EPW weather file belong in file
200
+ ports rendered to `input_files` / `resource_files`.
201
+
202
+ Backends should read files by role, and by future optional `port_key` when
203
+ available, not by assuming `input_files[0]` forever.
204
+
205
+ `ArtifactRef` and `FilePortContract` live in
206
+ `validibot_shared.validations.artifacts`. `InputFileItem` and
207
+ `ResourceFileItem` also accept an optional `port_key` so a backend or evidence
208
+ builder can correlate an envelope item back to the declared Validibot port
209
+ without relying only on backend role/type strings.
175
210
 
176
211
  ### Typed Subclassing Pattern
177
212
 
@@ -204,9 +239,14 @@ validibot_shared/
204
239
  ├── energyplus/ # EnergyPlus-specific models and envelopes
205
240
  │ ├── models.py # Simulation output models (metrics, results)
206
241
  │ └── envelopes.py # Typed envelope subclasses
207
- └── fmu/ # FMU-specific models
208
- ├── models.py # Probe result models
209
- └── envelopes.py # FMU envelope subclasses
242
+ ├── fmu/ # FMU-specific models
243
+ ├── models.py # Probe/simulation result models
244
+ └── envelopes.py # FMU envelope subclasses
245
+ ├── shacl/ # SHACL isolated-backend envelopes
246
+ │ └── envelopes.py # SHACL inputs/outputs and builder
247
+ └── schematron/ # Schematron isolated-backend envelopes
248
+ ├── envelopes.py # Schematron inputs/outputs and builder
249
+ └── svrl.py # SVRL parsing helpers
210
250
  ```
211
251
 
212
252
  ## Usage Examples
@@ -134,11 +134,46 @@ Supporting models include:
134
134
  | Class | Purpose |
135
135
  |-------|---------|
136
136
  | `InputFileItem` | File reference with URI, MIME type, and role |
137
+ | `ResourceFileItem` | Managed auxiliary file reference with URI and resource type |
137
138
  | `ValidatorInfo` | Validator identification (ID, type, version) |
138
139
  | `ExecutionContext` | Callback URL, execution bundle URI, timeout |
139
140
  | `ValidationMessage` | Individual finding (error, warning, info) |
140
141
  | `ValidationMetric` | Named numeric metric with optional unit |
141
142
  | `ValidationArtifact` | Output file reference (reports, logs, etc.) |
143
+ | `ArtifactRef` | Indexed run artifact reference for workflow/evidence control planes |
144
+ | `FilePortContract` | Shared file-port vocabulary for declared validator file inputs/outputs |
145
+
146
+ ### Designing File Inputs
147
+
148
+ The shared envelope exposes file inputs as `input_files` and `resource_files`.
149
+ Validator authors should still design those files as **declared ports** in the
150
+ core platform.
151
+
152
+ A file port answers:
153
+
154
+ - what the file means to the validator (`primary_model`, `weather_file`,
155
+ `data_graph`, `xml_document`, `schema_file`, `fmu_model`);
156
+ - how many files are valid (`1..1`, `0..1`, or a future collection);
157
+ - which envelope channel it renders into (`input_files` or `resource_files`);
158
+ - which backend role/type it uses (`primary-model`, `weather`, `fmu`,
159
+ `data-graph`);
160
+ - which formats and MIME types are accepted;
161
+ - whether the source may be a submitted file, workflow resource, upstream
162
+ artifact, or signal containing an artifact reference.
163
+
164
+ Keep small configuration in typed `inputs`. Use file/resource/artifact ports
165
+ for bytes. For example, EnergyPlus timestep settings belong in
166
+ `EnergyPlusInputs`; the IDF/epJSON model and EPW weather file belong in file
167
+ ports rendered to `input_files` / `resource_files`.
168
+
169
+ Backends should read files by role, and by future optional `port_key` when
170
+ available, not by assuming `input_files[0]` forever.
171
+
172
+ `ArtifactRef` and `FilePortContract` live in
173
+ `validibot_shared.validations.artifacts`. `InputFileItem` and
174
+ `ResourceFileItem` also accept an optional `port_key` so a backend or evidence
175
+ builder can correlate an envelope item back to the declared Validibot port
176
+ without relying only on backend role/type strings.
142
177
 
143
178
  ### Typed Subclassing Pattern
144
179
 
@@ -171,9 +206,14 @@ validibot_shared/
171
206
  ├── energyplus/ # EnergyPlus-specific models and envelopes
172
207
  │ ├── models.py # Simulation output models (metrics, results)
173
208
  │ └── envelopes.py # Typed envelope subclasses
174
- └── fmu/ # FMU-specific models
175
- ├── models.py # Probe result models
176
- └── envelopes.py # FMU envelope subclasses
209
+ ├── fmu/ # FMU-specific models
210
+ ├── models.py # Probe/simulation result models
211
+ └── envelopes.py # FMU envelope subclasses
212
+ ├── shacl/ # SHACL isolated-backend envelopes
213
+ │ └── envelopes.py # SHACL inputs/outputs and builder
214
+ └── schematron/ # Schematron isolated-backend envelopes
215
+ ├── envelopes.py # Schematron inputs/outputs and builder
216
+ └── svrl.py # SVRL parsing helpers
177
217
  ```
178
218
 
179
219
  ## Usage Examples
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "validibot-shared"
7
- version = "0.12.0"
7
+ version = "0.13.0"
8
8
  description = "Shared library for data interchange between Validibot and validator containers"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -37,7 +37,7 @@ classifiers = [
37
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.20"]
40
+ dev = ["pytest==9.1.1", "ruff==0.15.21"]
41
41
 
42
42
  [project.urls]
43
43
  Homepage = "https://validibot.com"
@@ -0,0 +1,101 @@
1
+ """Tests for shared artifact references and file-port contracts.
2
+
3
+ These schemas form the cross-repo contract between Django, validator
4
+ backends, evidence builders, and future UI/API projections. The tests focus
5
+ on JSON shape stability and enum serialization because those are the pieces
6
+ that must remain portable across repos.
7
+ """
8
+
9
+ import pytest
10
+ from pydantic import ValidationError
11
+
12
+ from validibot_shared.validations.artifacts import (
13
+ ArtifactKind,
14
+ ArtifactRef,
15
+ BindingSourceScope,
16
+ DefaultSourceStrategy,
17
+ EnvelopeChannel,
18
+ FilePortContract,
19
+ StepIODirection,
20
+ StepIOMedium,
21
+ )
22
+
23
+
24
+ def test_artifact_ref_serializes_as_json_safe_control_plane_object():
25
+ """ArtifactRef should carry lineage metadata without embedding bytes."""
26
+ ref = ArtifactRef(
27
+ artifact_id="artifact-1",
28
+ run_id="run-1",
29
+ step_run_id="step-run-1",
30
+ producer_step_key="build_model",
31
+ contract_key="generated_model",
32
+ name="model.epjson",
33
+ role="primary-model",
34
+ kind=ArtifactKind.FILE,
35
+ media_type="application/vnd.energyplus.epjson",
36
+ data_format="energyplus_epjson",
37
+ filename="model.epjson",
38
+ size_bytes=1234,
39
+ sha256="a" * 64,
40
+ uri="gs://bucket/runs/run-1/model.epjson",
41
+ producer_validator_type="BUILDINGSYNC_TO_ENERGYPLUS",
42
+ producer_validator_version="1",
43
+ producer_backend_image_digest="repo/image@sha256:" + "b" * 64,
44
+ retention_class="standard",
45
+ )
46
+
47
+ dumped = ref.model_dump(mode="json")
48
+
49
+ assert dumped["schema_version"] == "validibot.artifact_ref.v1"
50
+ assert dumped["kind"] == "file"
51
+ assert dumped["producer_step_key"] == "build_model"
52
+ assert dumped["uri"].startswith("gs://")
53
+
54
+
55
+ def test_file_port_contract_serializes_shared_vocabulary():
56
+ """File-port contracts should expose stable enum values to every repo."""
57
+ contract = FilePortContract(
58
+ contract_key="primary_model",
59
+ label="Primary Model",
60
+ direction=StepIODirection.INPUT,
61
+ io_medium=StepIOMedium.ARTIFACT,
62
+ envelope_channel=EnvelopeChannel.INPUT_FILES,
63
+ role="primary-model",
64
+ artifact_kind=ArtifactKind.FILE,
65
+ min_items=1,
66
+ max_items=1,
67
+ accepted_data_formats=["energyplus_idf", "energyplus_epjson"],
68
+ accepted_media_types=[
69
+ "application/vnd.energyplus.idf",
70
+ "application/vnd.energyplus.epjson",
71
+ ],
72
+ allowed_source_scopes=[
73
+ BindingSourceScope.SUBMISSION_FILE,
74
+ BindingSourceScope.UPSTREAM_ARTIFACT,
75
+ BindingSourceScope.SIGNAL,
76
+ ],
77
+ default_source_strategy=DefaultSourceStrategy.SUBMITTED_FILE_FIRST,
78
+ )
79
+
80
+ dumped = contract.model_dump(mode="json")
81
+
82
+ assert dumped["contract_key"] == "primary_model"
83
+ assert dumped["io_medium"] == "artifact"
84
+ assert dumped["envelope_channel"] == "input_files"
85
+ assert dumped["allowed_source_scopes"] == [
86
+ "submission_file",
87
+ "upstream_artifact",
88
+ "signal",
89
+ ]
90
+ assert dumped["default_source_strategy"] == "submitted_file_first"
91
+
92
+
93
+ def test_file_port_contract_rejects_extra_fields():
94
+ """The contract should stay explicit rather than absorbing typos."""
95
+ with pytest.raises(ValidationError):
96
+ FilePortContract(
97
+ contract_key="primary_model",
98
+ direction=StepIODirection.INPUT,
99
+ envelope_channel=EnvelopeChannel.INPUT_FILES,
100
+ unknown=True,
101
+ )
@@ -0,0 +1,100 @@
1
+ """Tests for shared RFC 8785 / JCS canonicalization helpers.
2
+
3
+ The workflow-definition hash and Pro credential signing path both depend on
4
+ these helpers producing stable bytes from the same logical JSON. Keeping these
5
+ tests in ``validibot-shared`` pins the contract at the package boundary used by
6
+ community evidence producers, commercial signing code, and third-party
7
+ verifiers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+
15
+ from pydantic import BaseModel, ConfigDict
16
+
17
+ from validibot_shared.canonicalization import (
18
+ canonicalize_dict,
19
+ canonicalize_model,
20
+ sha256_hex_for_dict,
21
+ sha256_hex_for_model,
22
+ )
23
+
24
+ UNSAFE_INTEGER = 9007199254740993
25
+
26
+
27
+ class SampleSchema(BaseModel):
28
+ """Small frozen schema matching the credential-builder model pattern."""
29
+
30
+ model_config = ConfigDict(extra="forbid", frozen=True)
31
+
32
+ alpha: str
33
+ beta: int
34
+ gamma: str | None = None
35
+
36
+
37
+ # ── RFC 8785 ordering and scalar handling ───────────────────────────────────
38
+ # These tests protect the cross-language JSON contract that hashes/signatures
39
+ # depend on: same logical object, same canonical bytes everywhere.
40
+
41
+
42
+ def test_dict_keys_are_sorted_recursively() -> None:
43
+ """Object keys must be sorted at every level of the JSON tree."""
44
+ result = canonicalize_dict(
45
+ {"outer_z": {"inner_b": 2, "inner_a": 1}, "outer_a": 0},
46
+ )
47
+ decoded = result.decode("utf-8")
48
+ assert decoded.index('"outer_a"') < decoded.index('"outer_z"')
49
+ assert decoded.index('"inner_a"') < decoded.index('"inner_b"')
50
+
51
+
52
+ def test_large_integer_is_preserved_exactly() -> None:
53
+ """Integers larger than 2**53 must not be rounded through ``float``.
54
+
55
+ Credential signatures may include large identifiers or nonces. If JCS casts
56
+ them to IEEE-754 doubles, the canonical bytes silently change and a
57
+ standards-compliant verifier computes a different digest.
58
+ """
59
+ result = canonicalize_dict({"n": UNSAFE_INTEGER})
60
+ assert result == b'{"n":9007199254740993}'
61
+ assert b"9007199254740992" not in result
62
+
63
+
64
+ def test_bool_still_serializes_as_json_literals() -> None:
65
+ """``bool`` must remain JSON ``true``/``false`` despite subclassing ``int``."""
66
+ assert canonicalize_dict({"yes": True, "no": False}) == (b'{"no":false,"yes":true}')
67
+
68
+
69
+ def test_unicode_strings_are_preserved() -> None:
70
+ """Non-ASCII text must round-trip through canonical bytes unchanged."""
71
+ result = canonicalize_dict({"name": "Prüfung", "description": "日本語テスト"})
72
+ parsed = json.loads(result)
73
+ assert parsed["name"] == "Prüfung"
74
+ assert parsed["description"] == "日本語テスト"
75
+
76
+
77
+ # ── Pydantic integration and digest wrappers ────────────────────────────────
78
+ # Producers build Pydantic models, while external verifiers often reconstruct
79
+ # plain dictionaries. Both paths must canonicalize to identical bytes.
80
+
81
+
82
+ def test_model_produces_same_bytes_as_equivalent_dict() -> None:
83
+ """Pydantic model dumping must match an equivalent JSON dictionary."""
84
+ schema = SampleSchema(alpha="hello", beta=42, gamma=None)
85
+ plain = {"alpha": "hello", "beta": 42, "gamma": None}
86
+ assert canonicalize_model(schema) == canonicalize_dict(plain)
87
+
88
+
89
+ def test_sha256_for_dict_matches_manual_hash() -> None:
90
+ """The dict digest helper must hash the canonical byte preimage exactly."""
91
+ data = {"key": "value", "number": 7}
92
+ expected = hashlib.sha256(canonicalize_dict(data)).hexdigest()
93
+ assert sha256_hex_for_dict(data) == expected
94
+
95
+
96
+ def test_sha256_for_model_matches_manual_hash() -> None:
97
+ """The model digest helper must hash the canonical byte preimage exactly."""
98
+ schema = SampleSchema(alpha="test", beta=99, gamma="yes")
99
+ expected = hashlib.sha256(canonicalize_model(schema)).hexdigest()
100
+ assert sha256_hex_for_model(schema) == expected
@@ -9,6 +9,7 @@ from validibot_shared.validations.envelopes import (
9
9
  ExecutionContext,
10
10
  InputFileItem,
11
11
  MessageLocation,
12
+ ResourceFileItem,
12
13
  Severity,
13
14
  SupportedMimeType,
14
15
  ValidationCallback,
@@ -51,9 +52,35 @@ def test_validation_input_envelope_defaults_schema_version():
51
52
 
52
53
  assert envelope.schema_version == "validibot.input.v1"
53
54
  assert envelope.input_files[0].role == "primary-model"
55
+ assert envelope.input_files[0].port_key is None
54
56
  assert envelope.inputs == {}
55
57
 
56
58
 
59
+ def test_input_file_item_accepts_optional_port_key():
60
+ """InputFileItem should carry declared file-port identity when available."""
61
+ item = InputFileItem(
62
+ name="model.idf",
63
+ mime_type=SupportedMimeType.ENERGYPLUS_IDF,
64
+ role="primary-model",
65
+ port_key="primary_model",
66
+ uri="gs://bucket/model.idf",
67
+ )
68
+
69
+ assert item.port_key == "primary_model"
70
+
71
+
72
+ def test_resource_file_item_accepts_optional_port_key():
73
+ """ResourceFileItem should carry declared file-port identity when available."""
74
+ item = ResourceFileItem(
75
+ id="resource-1",
76
+ type="energyplus_weather",
77
+ port_key="weather_file",
78
+ uri="gs://bucket/weather.epw",
79
+ )
80
+
81
+ assert item.port_key == "weather_file"
82
+
83
+
57
84
  def test_input_file_item_forbids_extra_fields():
58
85
  """InputFileItem should forbid extra fields."""
59
86
  with pytest.raises(ValidationError):
@@ -1,5 +1,11 @@
1
1
  """Shared domain models and utilities for Validibot."""
2
2
 
3
+ from validibot_shared.canonicalization import (
4
+ canonicalize_dict,
5
+ canonicalize_model,
6
+ sha256_hex_for_dict,
7
+ sha256_hex_for_model,
8
+ )
3
9
  from validibot_shared.energyplus.models import (
4
10
  EnergyPlusSimulationLogs,
5
11
  EnergyPlusSimulationMetrics,
@@ -30,6 +36,8 @@ __all__ = [
30
36
  "EVIDENCE_SCHEMA_VERSION",
31
37
  "ContractConstant",
32
38
  "ContractSignalMapping",
39
+ "canonicalize_dict",
40
+ "canonicalize_model",
33
41
  "EnergyPlusSimulationLogs",
34
42
  "EnergyPlusSimulationMetrics",
35
43
  "EnergyPlusSimulationOutputs",
@@ -43,6 +51,8 @@ __all__ = [
43
51
  "SHACLOutputEnvelope",
44
52
  "SHACLOutputs",
45
53
  "SHACLSparqlAssertionSpec",
54
+ "sha256_hex_for_dict",
55
+ "sha256_hex_for_model",
46
56
  "StepValidatorRecord",
47
57
  "WorkflowContractSnapshot",
48
58
  ]
@@ -0,0 +1 @@
1
+ """Vendored compatibility code used by ``validibot_shared`` internals."""
@@ -0,0 +1,191 @@
1
+ """
2
+ Vendored RFC 8785 JSON Canonicalization Scheme (JCS) implementation.
3
+
4
+ Vendored from: jcs 0.2.1 (https://pypi.org/project/jcs/)
5
+ Original author: WebPKI.org (http://webpki.org)
6
+ License: Apache License, Version 2.0
7
+
8
+ Copyright 2006-2019 WebPKI.org (http://webpki.org).
9
+
10
+ Licensed under the Apache License, Version 2.0 (the "License");
11
+ you may not use this file except in compliance with the License.
12
+ You may obtain a copy of the License at
13
+
14
+ https://www.apache.org/licenses/LICENSE-2.0
15
+
16
+ Unless required by applicable law or agreed to in writing, software
17
+ distributed under the License is distributed on an "AS IS" BASIS,
18
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19
+ See the License for the specific language governing permissions and
20
+ limitations under the License.
21
+
22
+ This file combines _jcs.py and ntoj.py from the original package
23
+ into a single module for simplicity. The public API is the single
24
+ ``canonicalize(obj)`` function.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # ntoj: Convert Python float/int into an ES6/V8-compatible JSON string
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def _convert_to_es6_format(value):
37
+ """Convert a Python number to its ES6/V8 canonical string representation.
38
+
39
+ This handles the tricky parts of RFC 8785 number serialization:
40
+ no trailing zeros, no positive sign, specific exponent formatting,
41
+ and special handling for zero and small/large values.
42
+ """
43
+ fvalue = float(value)
44
+
45
+ # Zero is a special case. Handles "-0" as well.
46
+ if fvalue == 0:
47
+ return "0"
48
+
49
+ py_double = str(fvalue)
50
+
51
+ # Catch "inf" and "nan" values
52
+ if "n" in py_double:
53
+ msg = f"Invalid JSON number: {py_double}"
54
+ raise ValueError(msg)
55
+
56
+ # Save sign separately
57
+ py_sign = ""
58
+ if py_double.startswith("-"):
59
+ py_sign = "-"
60
+ py_double = py_double[1:]
61
+
62
+ # Extract exponent if present
63
+ py_exp_str = ""
64
+ py_exp_val = 0
65
+ q = py_double.find("e")
66
+ if q > 0:
67
+ py_exp_str = py_double[q:]
68
+ if py_exp_str[2:3] == "0":
69
+ # Suppress leading zero on exponents
70
+ py_exp_str = py_exp_str[:2] + py_exp_str[3:]
71
+ py_double = py_double[:q]
72
+ py_exp_val = int(py_exp_str[1:])
73
+
74
+ # Split number into first + dot + last
75
+ py_first = py_double
76
+ py_dot = ""
77
+ py_last = ""
78
+ q = py_double.find(".")
79
+ if q > 0:
80
+ py_dot = "."
81
+ py_first = py_double[:q]
82
+ py_last = py_double[q + 1 :]
83
+
84
+ # Always remove trailing .0
85
+ if py_last == "0":
86
+ py_dot = ""
87
+ py_last = ""
88
+
89
+ if 0 < py_exp_val < 21:
90
+ # Integers shown as-is with up to 21 digits
91
+ py_first += py_last
92
+ py_last = ""
93
+ py_dot = ""
94
+ py_exp_str = ""
95
+ q = py_exp_val - len(py_first)
96
+ while q >= 0:
97
+ q -= 1
98
+ py_first += "0"
99
+ elif -7 < py_exp_val < 0:
100
+ # Small numbers shown as 0.etc with e-6 as lower limit
101
+ py_last = py_first + py_last
102
+ py_first = "0"
103
+ py_dot = "."
104
+ py_exp_str = ""
105
+ q = py_exp_val
106
+ while q < -1:
107
+ q += 1
108
+ py_last = "0" + py_last
109
+
110
+ return py_sign + py_first + py_dot + py_last + py_exp_str
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # _jcs: JCS-compatible JSON encoder
115
+ # ---------------------------------------------------------------------------
116
+
117
+ _ESCAPE = re.compile(r'[\x00-\x1f\\"\b\f\n\r\t]')
118
+ _ESCAPE_DCT = {
119
+ "\\": "\\\\",
120
+ '"': '\\"',
121
+ "\b": "\\b",
122
+ "\f": "\\f",
123
+ "\n": "\\n",
124
+ "\r": "\\r",
125
+ "\t": "\\t",
126
+ }
127
+ for _i in range(0x20):
128
+ _ESCAPE_DCT.setdefault(chr(_i), f"\\u{_i:04x}")
129
+
130
+
131
+ def _encode_basestring(s):
132
+ """Return a JSON representation of a Python string (non-ASCII preserved)."""
133
+
134
+ def replace(match):
135
+ return _ESCAPE_DCT[match.group(0)]
136
+
137
+ return '"' + _ESCAPE.sub(replace, s) + '"'
138
+
139
+
140
+ def _serialize(obj):
141
+ """Recursively serialize a Python object to a JCS-canonical JSON string.
142
+
143
+ Keys are sorted by UTF-16BE byte order per RFC 8785 §3.2.3.
144
+ """
145
+ if isinstance(obj, str):
146
+ return _encode_basestring(obj)
147
+ if obj is None:
148
+ return "null"
149
+ if obj is True:
150
+ return "true"
151
+ if obj is False:
152
+ return "false"
153
+ if isinstance(obj, int):
154
+ # RFC 8785 §3.2.2.3 mandates that JSON integers be emitted as their
155
+ # exact decimal literal. We must NOT route ``int`` through the ES6
156
+ # number formatter, because that helper begins ``float(value)`` which
157
+ # loses precision for any integer whose magnitude exceeds 2**53
158
+ # (the largest exactly-representable IEEE-754 double). Corrupting the
159
+ # value here would silently break cross-language canonical-hash
160
+ # reproducibility (e.g. signature verification against a non-Python
161
+ # signer). ``str(int)`` already yields the canonical literal: no
162
+ # leading zeros, a single optional ``-`` sign, and no ``+``/exponent.
163
+ # ``bool`` is handled above (it is a subclass of ``int``) so it never
164
+ # reaches this branch and still serializes as ``true``/``false``.
165
+ return str(obj)
166
+ if isinstance(obj, float):
167
+ return _convert_to_es6_format(obj)
168
+ if isinstance(obj, (list, tuple)):
169
+ return "[" + ",".join(_serialize(v) for v in obj) + "]"
170
+ if isinstance(obj, dict):
171
+ items = sorted(obj.items(), key=lambda kv: kv[0].encode("utf-16-be"))
172
+ return (
173
+ "{"
174
+ + ",".join(_encode_basestring(k) + ":" + _serialize(v) for k, v in items)
175
+ + "}"
176
+ )
177
+ msg = f"Object of type '{type(obj).__name__}' is not JSON serializable"
178
+ raise TypeError(msg)
179
+
180
+
181
+ def canonicalize(obj: object) -> bytes:
182
+ """Serialize a Python object to RFC 8785 canonical JSON bytes.
183
+
184
+ Args:
185
+ obj: A JSON-serializable Python object (dict, list, str,
186
+ int, float, bool, None).
187
+
188
+ Returns:
189
+ UTF-8 encoded canonical JSON bytes.
190
+ """
191
+ return _serialize(obj).encode("utf-8")
@@ -0,0 +1,57 @@
1
+ """RFC 8785 / JCS canonical JSON helpers shared across Validibot packages.
2
+
3
+ The evidence manifest producer, workflow-definition digest, and Pro credential
4
+ signing path all need the same deterministic byte representation. Keeping the
5
+ canonicalizer in ``validibot-shared`` gives community evidence producers,
6
+ commercial signing code, and third-party verifiers one portable contract instead
7
+ of a Pro-owned implementation detail.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel
16
+
17
+ from validibot_shared._vendor.jcs import canonicalize as _jcs_canonicalize
18
+
19
+
20
+ def canonicalize_dict(data: dict[str, Any]) -> bytes:
21
+ """Canonicalize a plain dictionary via RFC 8785 / JCS.
22
+
23
+ Args:
24
+ data: A JSON-serializable dictionary.
25
+
26
+ Returns:
27
+ Canonical UTF-8 encoded JSON bytes per RFC 8785.
28
+ """
29
+ return _jcs_canonicalize(data)
30
+
31
+
32
+ def sha256_hex_for_dict(data: dict[str, Any]) -> str:
33
+ """Compute the SHA-256 hex digest of a plain dict's canonical bytes."""
34
+ return hashlib.sha256(canonicalize_dict(data)).hexdigest()
35
+
36
+
37
+ def canonicalize_model(schema_obj: BaseModel) -> bytes:
38
+ """Canonicalize a Pydantic model via ``model_dump(mode="json")`` + JCS.
39
+
40
+ ``mode="json"`` converts UUIDs, datetimes, and other Pydantic-native values
41
+ to JSON-compatible scalars before canonicalization. ``by_alias=True`` keeps
42
+ wire aliases such as ``@context`` intact.
43
+ """
44
+ return canonicalize_dict(schema_obj.model_dump(mode="json", by_alias=True))
45
+
46
+
47
+ def sha256_hex_for_model(schema_obj: BaseModel) -> str:
48
+ """Compute the SHA-256 hex digest of a Pydantic model's canonical bytes."""
49
+ return hashlib.sha256(canonicalize_model(schema_obj)).hexdigest()
50
+
51
+
52
+ __all__ = [
53
+ "canonicalize_dict",
54
+ "canonicalize_model",
55
+ "sha256_hex_for_dict",
56
+ "sha256_hex_for_model",
57
+ ]
@@ -0,0 +1,155 @@
1
+ """Shared artifact references and file-port contract vocabulary.
2
+
3
+ These schemas are control-plane objects. They describe files, resources, and
4
+ artifact references that Django materializes into validator envelopes; they do
5
+ not contain file bytes or Django workflow models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+ from typing import Any, Literal
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ ARTIFACT_REF_SCHEMA_VERSION = "validibot.artifact_ref.v1"
16
+
17
+
18
+ class ArtifactKind(str, Enum):
19
+ """Storage-backed artifact kind."""
20
+
21
+ FILE = "file"
22
+ DIRECTORY = "directory"
23
+ ARCHIVE = "archive"
24
+ DATASET = "dataset"
25
+ REPORT = "report"
26
+ LOG = "log"
27
+ OTHER = "other"
28
+
29
+
30
+ class StepIODirection(str, Enum):
31
+ """Whether a step port is consumed or produced."""
32
+
33
+ INPUT = "input"
34
+ OUTPUT = "output"
35
+
36
+
37
+ class StepIOMedium(str, Enum):
38
+ """Whether a step port carries a JSON value or artifact reference."""
39
+
40
+ VALUE = "value"
41
+ ARTIFACT = "artifact"
42
+
43
+
44
+ class EnvelopeChannel(str, Enum):
45
+ """Wire-level envelope location for a resolved file port."""
46
+
47
+ INPUT_FILES = "input_files"
48
+ RESOURCE_FILES = "resource_files"
49
+ OUTPUT_ARTIFACTS = "output_artifacts"
50
+
51
+
52
+ class BindingSourceScope(str, Enum):
53
+ """Source scopes available to file-port and value bindings."""
54
+
55
+ SUBMISSION_PAYLOAD = "submission_payload"
56
+ SUBMISSION_METADATA = "submission_metadata"
57
+ SUBMISSION_FILE = "submission_file"
58
+ UPSTREAM_STEP = "upstream_step"
59
+ UPSTREAM_ARTIFACT = "upstream_artifact"
60
+ SIGNAL = "signal"
61
+ CONSTANT = "constant"
62
+ WORKFLOW_RESOURCE = "workflow_resource"
63
+ SYSTEM = "system"
64
+
65
+
66
+ class DefaultSourceStrategy(str, Enum):
67
+ """Automatic or suggested source-selection behavior for a file port."""
68
+
69
+ SUBMITTED_FILE_FIRST = "submitted_file_first"
70
+ SUBMITTED_FILE_THEN_DEFAULT_RESOURCE = "submitted_file_then_default_resource"
71
+ WORKFLOW_RESOURCE_DEFAULT = "workflow_resource_default"
72
+ UPSTREAM_ARTIFACT_SUGGESTION = "upstream_artifact_suggestion"
73
+ MANUAL = "manual"
74
+ NONE = "none"
75
+
76
+
77
+ class FilePortContract(BaseModel):
78
+ """Shared description of a file-like step input or output port."""
79
+
80
+ contract_key: str = Field(
81
+ description="Stable Validibot port key, e.g. primary_model."
82
+ )
83
+ label: str = Field(default="", description="Human-facing port label.")
84
+ direction: StepIODirection = Field(description="Whether this port is input/output.")
85
+ io_medium: StepIOMedium = Field(
86
+ default=StepIOMedium.ARTIFACT,
87
+ description="File ports carry artifact references.",
88
+ )
89
+ envelope_channel: EnvelopeChannel = Field(
90
+ description="Envelope channel this port renders into."
91
+ )
92
+ role: str = Field(
93
+ default="",
94
+ description="Backend-facing role for input_files or output artifacts.",
95
+ )
96
+ resource_type: str = Field(
97
+ default="",
98
+ description="Backend-facing resource type for resource_files.",
99
+ )
100
+ artifact_kind: ArtifactKind = Field(
101
+ default=ArtifactKind.FILE,
102
+ description="Expected kind for artifact references.",
103
+ )
104
+ min_items: int = Field(default=0, ge=0, description="Minimum item count.")
105
+ max_items: int | None = Field(
106
+ default=1,
107
+ ge=1,
108
+ description="Maximum item count; None means deliberately unbounded.",
109
+ )
110
+ accepted_data_formats: list[str] = Field(default_factory=list)
111
+ accepted_media_types: list[str] = Field(default_factory=list)
112
+ allowed_source_scopes: list[BindingSourceScope] = Field(default_factory=list)
113
+ default_source_strategy: DefaultSourceStrategy = DefaultSourceStrategy.NONE
114
+ metadata: dict[str, Any] = Field(default_factory=dict)
115
+
116
+ model_config = {"extra": "forbid"}
117
+
118
+
119
+ class ArtifactRef(BaseModel):
120
+ """Small JSON-safe pointer to a run-scoped artifact."""
121
+
122
+ schema_version: Literal["validibot.artifact_ref.v1"] = ARTIFACT_REF_SCHEMA_VERSION
123
+
124
+ artifact_id: str
125
+ run_id: str
126
+ step_run_id: str
127
+ producer_step_key: str
128
+
129
+ contract_key: str
130
+ name: str
131
+ role: str = ""
132
+ kind: ArtifactKind = ArtifactKind.FILE
133
+
134
+ media_type: str = ""
135
+ data_format: str = ""
136
+ filename: str = ""
137
+ size_bytes: int | None = None
138
+ sha256: str = ""
139
+
140
+ uri: str = Field(
141
+ default="",
142
+ description="Internal storage URI; not a public download URL.",
143
+ )
144
+ manifest_uri: str = ""
145
+ manifest_sha256: str = ""
146
+
147
+ producer_validator_type: str = ""
148
+ producer_validator_version: str = ""
149
+ producer_backend_image_digest: str = ""
150
+
151
+ retention_class: str = ""
152
+ labels: list[str] = Field(default_factory=list)
153
+ metadata: dict[str, Any] = Field(default_factory=dict)
154
+
155
+ model_config = {"extra": "forbid"}
@@ -205,6 +205,14 @@ class InputFileItem(BaseModel):
205
205
  description="Validator-specific role (e.g., 'primary-model', 'config')",
206
206
  )
207
207
 
208
+ port_key: str | None = Field(
209
+ default=None,
210
+ description=(
211
+ "Declared Validibot file-port key that produced this envelope item "
212
+ "(e.g., 'primary_model'). Optional for backwards-compatible rollout."
213
+ ),
214
+ )
215
+
208
216
  uri: str = Field(
209
217
  description="Storage URI to the file (gs:// or file:// for self-hosted)"
210
218
  )
@@ -232,6 +240,14 @@ class ResourceFileItem(BaseModel):
232
240
  description="Resource type (e.g., 'weather', 'library', 'config')"
233
241
  )
234
242
 
243
+ port_key: str | None = Field(
244
+ default=None,
245
+ description=(
246
+ "Declared Validibot file-port key that produced this resource item "
247
+ "(e.g., 'weather_file'). Optional for backwards-compatible rollout."
248
+ ),
249
+ )
250
+
235
251
  uri: str = Field(
236
252
  description="Storage URI to the file (gs:// or file:// for self-hosted)"
237
253
  )