validibot-shared 0.10.0__tar.gz → 0.17.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.17.0}/PKG-INFO +69 -8
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/README.md +66 -6
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/pyproject.toml +3 -3
- validibot_shared-0.17.0/tests/test_artifacts.py +124 -0
- validibot_shared-0.17.0/tests/test_callback_nonce_contract.py +144 -0
- validibot_shared-0.17.0/tests/test_canonicalization.py +163 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_energyplus_envelopes.py +21 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_evidence_manifest.py +78 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_fmu_envelopes.py +26 -1
- validibot_shared-0.17.0/tests/test_schematron_envelopes.py +224 -0
- validibot_shared-0.17.0/tests/test_schematron_svrl.py +288 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_shacl_envelopes.py +36 -7
- validibot_shared-0.17.0/tests/test_validations_envelopes.py +333 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/__init__.py +12 -0
- validibot_shared-0.17.0/validibot_shared/_vendor/__init__.py +1 -0
- validibot_shared-0.17.0/validibot_shared/_vendor/jcs.py +191 -0
- validibot_shared-0.17.0/validibot_shared/canonicalization.py +131 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/energyplus/envelopes.py +2 -2
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/energyplus/models.py +4 -4
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/evidence/__init__.py +6 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/evidence/manifest.py +180 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/fmu/envelopes.py +29 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/fmu/models.py +1 -1
- validibot_shared-0.17.0/validibot_shared/schematron/__init__.py +49 -0
- validibot_shared-0.17.0/validibot_shared/schematron/envelopes.py +319 -0
- validibot_shared-0.17.0/validibot_shared/schematron/svrl.py +277 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/shacl/envelopes.py +28 -5
- validibot_shared-0.17.0/validibot_shared/validations/artifacts.py +171 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/validations/envelopes.py +196 -12
- validibot_shared-0.10.0/tests/test_validations_envelopes.py +0 -144
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/.gitignore +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/LICENSE +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/NOTICE +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_energyplus_models.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_fmu_models.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/tests/test_package_init.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/energyplus/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/fmu/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/py.typed +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.0}/validibot_shared/shacl/__init__.py +0 -0
- {validibot_shared-0.10.0 → validibot_shared-0.17.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.17.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.21; extra == 'dev'
|
|
31
32
|
Description-Content-Type: text/markdown
|
|
32
33
|
|
|
33
34
|
<div align="center">
|
|
@@ -165,12 +166,49 @@ Supporting models include:
|
|
|
165
166
|
|
|
166
167
|
| Class | Purpose |
|
|
167
168
|
|-------|---------|
|
|
168
|
-
| `InputFileItem` | File reference with URI, MIME type, and
|
|
169
|
+
| `InputFileItem` | File reference with URI, MIME type, role, exact size, SHA-256, and immutable storage version |
|
|
170
|
+
| `ResourceFileItem` | Managed auxiliary file reference with safe name, URI, resource type, and byte identity |
|
|
169
171
|
| `ValidatorInfo` | Validator identification (ID, type, version) |
|
|
170
|
-
| `ExecutionContext` |
|
|
172
|
+
| `ExecutionContext` | Attempt identity, callback nonce commitment, bundle URI, and timeout |
|
|
171
173
|
| `ValidationMessage` | Individual finding (error, warning, info) |
|
|
172
174
|
| `ValidationMetric` | Named numeric metric with optional unit |
|
|
173
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 `port_key` when available, not by
|
|
203
|
+
assuming `input_files[0]` forever. Every file item commits to an exact size,
|
|
204
|
+
SHA-256, and provider-specific immutable storage version; runtimes must verify
|
|
205
|
+
those fields while streaming before a validator parses or executes the bytes.
|
|
206
|
+
|
|
207
|
+
`ArtifactRef` and `FilePortContract` live in
|
|
208
|
+
`validibot_shared.validations.artifacts`. `InputFileItem` and
|
|
209
|
+
`ResourceFileItem` also accept an optional `port_key` so a backend or evidence
|
|
210
|
+
builder can correlate an envelope item back to the declared Validibot port
|
|
211
|
+
without relying only on backend role/type strings.
|
|
174
212
|
|
|
175
213
|
### Typed Subclassing Pattern
|
|
176
214
|
|
|
@@ -203,9 +241,14 @@ validibot_shared/
|
|
|
203
241
|
├── energyplus/ # EnergyPlus-specific models and envelopes
|
|
204
242
|
│ ├── models.py # Simulation output models (metrics, results)
|
|
205
243
|
│ └── envelopes.py # Typed envelope subclasses
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
244
|
+
├── fmu/ # FMU-specific models
|
|
245
|
+
│ ├── models.py # Probe/simulation result models
|
|
246
|
+
│ └── envelopes.py # FMU envelope subclasses
|
|
247
|
+
├── shacl/ # SHACL isolated-backend envelopes
|
|
248
|
+
│ └── envelopes.py # SHACL inputs/outputs and builder
|
|
249
|
+
└── schematron/ # Schematron isolated-backend envelopes
|
|
250
|
+
├── envelopes.py # Schematron inputs/outputs and builder
|
|
251
|
+
└── svrl.py # SVRL parsing helpers
|
|
209
252
|
```
|
|
210
253
|
|
|
211
254
|
## Usage Examples
|
|
@@ -213,8 +256,12 @@ validibot_shared/
|
|
|
213
256
|
### Creating an Input Envelope
|
|
214
257
|
|
|
215
258
|
```python
|
|
259
|
+
import secrets
|
|
260
|
+
|
|
261
|
+
from validibot_shared.canonicalization import compute_callback_nonce_commitment
|
|
216
262
|
from validibot_shared.energyplus import EnergyPlusInputEnvelope, EnergyPlusInputs
|
|
217
263
|
from validibot_shared.validations.envelopes import (
|
|
264
|
+
ATTEMPT_CONTRACT_VERSION,
|
|
218
265
|
ExecutionContext,
|
|
219
266
|
InputFileItem,
|
|
220
267
|
OrganizationInfo,
|
|
@@ -224,6 +271,8 @@ from validibot_shared.validations.envelopes import (
|
|
|
224
271
|
WorkflowInfo,
|
|
225
272
|
)
|
|
226
273
|
|
|
274
|
+
callback_nonce = secrets.token_urlsafe(32)
|
|
275
|
+
|
|
227
276
|
envelope = EnergyPlusInputEnvelope(
|
|
228
277
|
run_id="run-123",
|
|
229
278
|
validator=ValidatorInfo(
|
|
@@ -243,12 +292,24 @@ envelope = EnergyPlusInputEnvelope(
|
|
|
243
292
|
mime_type=SupportedMimeType.ENERGYPLUS_IDF,
|
|
244
293
|
role="primary-model",
|
|
245
294
|
uri="gs://bucket/model.idf",
|
|
295
|
+
size_bytes=12345,
|
|
296
|
+
sha256="0123456789abcdef" * 4,
|
|
297
|
+
storage_version="1700000000000000",
|
|
246
298
|
),
|
|
247
299
|
],
|
|
248
300
|
inputs=EnergyPlusInputs(timestep_per_hour=4),
|
|
249
301
|
context=ExecutionContext(
|
|
250
302
|
callback_url="https://api.example.com/callback",
|
|
251
|
-
|
|
303
|
+
callback_id="execution-attempt-attempt-123",
|
|
304
|
+
callback_nonce=callback_nonce,
|
|
305
|
+
callback_nonce_commitment=compute_callback_nonce_commitment(callback_nonce),
|
|
306
|
+
execution_bundle_uri="gs://bucket/runs/org-123/run-123/attempts/attempt-123/",
|
|
307
|
+
execution_attempt_id="attempt-123",
|
|
308
|
+
step_run_id="step-run-789",
|
|
309
|
+
attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
|
|
310
|
+
expected_output_uri=(
|
|
311
|
+
"gs://bucket/runs/org-123/run-123/attempts/attempt-123/output.json"
|
|
312
|
+
),
|
|
252
313
|
),
|
|
253
314
|
)
|
|
254
315
|
|
|
@@ -133,12 +133,49 @@ Supporting models include:
|
|
|
133
133
|
|
|
134
134
|
| Class | Purpose |
|
|
135
135
|
|-------|---------|
|
|
136
|
-
| `InputFileItem` | File reference with URI, MIME type, and
|
|
136
|
+
| `InputFileItem` | File reference with URI, MIME type, role, exact size, SHA-256, and immutable storage version |
|
|
137
|
+
| `ResourceFileItem` | Managed auxiliary file reference with safe name, URI, resource type, and byte identity |
|
|
137
138
|
| `ValidatorInfo` | Validator identification (ID, type, version) |
|
|
138
|
-
| `ExecutionContext` |
|
|
139
|
+
| `ExecutionContext` | Attempt identity, callback nonce commitment, bundle URI, and 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 `port_key` when available, not by
|
|
170
|
+
assuming `input_files[0]` forever. Every file item commits to an exact size,
|
|
171
|
+
SHA-256, and provider-specific immutable storage version; runtimes must verify
|
|
172
|
+
those fields while streaming before a validator parses or executes the bytes.
|
|
173
|
+
|
|
174
|
+
`ArtifactRef` and `FilePortContract` live in
|
|
175
|
+
`validibot_shared.validations.artifacts`. `InputFileItem` and
|
|
176
|
+
`ResourceFileItem` also accept an optional `port_key` so a backend or evidence
|
|
177
|
+
builder can correlate an envelope item back to the declared Validibot port
|
|
178
|
+
without relying only on backend role/type strings.
|
|
142
179
|
|
|
143
180
|
### Typed Subclassing Pattern
|
|
144
181
|
|
|
@@ -171,9 +208,14 @@ validibot_shared/
|
|
|
171
208
|
├── energyplus/ # EnergyPlus-specific models and envelopes
|
|
172
209
|
│ ├── models.py # Simulation output models (metrics, results)
|
|
173
210
|
│ └── envelopes.py # Typed envelope subclasses
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
211
|
+
├── fmu/ # FMU-specific models
|
|
212
|
+
│ ├── models.py # Probe/simulation result models
|
|
213
|
+
│ └── envelopes.py # FMU envelope subclasses
|
|
214
|
+
├── shacl/ # SHACL isolated-backend envelopes
|
|
215
|
+
│ └── envelopes.py # SHACL inputs/outputs and builder
|
|
216
|
+
└── schematron/ # Schematron isolated-backend envelopes
|
|
217
|
+
├── envelopes.py # Schematron inputs/outputs and builder
|
|
218
|
+
└── svrl.py # SVRL parsing helpers
|
|
177
219
|
```
|
|
178
220
|
|
|
179
221
|
## Usage Examples
|
|
@@ -181,8 +223,12 @@ validibot_shared/
|
|
|
181
223
|
### Creating an Input Envelope
|
|
182
224
|
|
|
183
225
|
```python
|
|
226
|
+
import secrets
|
|
227
|
+
|
|
228
|
+
from validibot_shared.canonicalization import compute_callback_nonce_commitment
|
|
184
229
|
from validibot_shared.energyplus import EnergyPlusInputEnvelope, EnergyPlusInputs
|
|
185
230
|
from validibot_shared.validations.envelopes import (
|
|
231
|
+
ATTEMPT_CONTRACT_VERSION,
|
|
186
232
|
ExecutionContext,
|
|
187
233
|
InputFileItem,
|
|
188
234
|
OrganizationInfo,
|
|
@@ -192,6 +238,8 @@ from validibot_shared.validations.envelopes import (
|
|
|
192
238
|
WorkflowInfo,
|
|
193
239
|
)
|
|
194
240
|
|
|
241
|
+
callback_nonce = secrets.token_urlsafe(32)
|
|
242
|
+
|
|
195
243
|
envelope = EnergyPlusInputEnvelope(
|
|
196
244
|
run_id="run-123",
|
|
197
245
|
validator=ValidatorInfo(
|
|
@@ -211,12 +259,24 @@ envelope = EnergyPlusInputEnvelope(
|
|
|
211
259
|
mime_type=SupportedMimeType.ENERGYPLUS_IDF,
|
|
212
260
|
role="primary-model",
|
|
213
261
|
uri="gs://bucket/model.idf",
|
|
262
|
+
size_bytes=12345,
|
|
263
|
+
sha256="0123456789abcdef" * 4,
|
|
264
|
+
storage_version="1700000000000000",
|
|
214
265
|
),
|
|
215
266
|
],
|
|
216
267
|
inputs=EnergyPlusInputs(timestep_per_hour=4),
|
|
217
268
|
context=ExecutionContext(
|
|
218
269
|
callback_url="https://api.example.com/callback",
|
|
219
|
-
|
|
270
|
+
callback_id="execution-attempt-attempt-123",
|
|
271
|
+
callback_nonce=callback_nonce,
|
|
272
|
+
callback_nonce_commitment=compute_callback_nonce_commitment(callback_nonce),
|
|
273
|
+
execution_bundle_uri="gs://bucket/runs/org-123/run-123/attempts/attempt-123/",
|
|
274
|
+
execution_attempt_id="attempt-123",
|
|
275
|
+
step_run_id="step-run-789",
|
|
276
|
+
attempt_contract_version=ATTEMPT_CONTRACT_VERSION,
|
|
277
|
+
expected_output_uri=(
|
|
278
|
+
"gs://bucket/runs/org-123/run-123/attempts/attempt-123/output.json"
|
|
279
|
+
),
|
|
220
280
|
),
|
|
221
281
|
)
|
|
222
282
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "validibot-shared"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.17.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.21"]
|
|
41
41
|
|
|
42
42
|
[project.urls]
|
|
43
43
|
Homepage = "https://validibot.com"
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
storage_version="1700000000000000",
|
|
41
|
+
uri="gs://bucket/runs/run-1/model.epjson",
|
|
42
|
+
producer_validator_type="BUILDINGSYNC_TO_ENERGYPLUS",
|
|
43
|
+
producer_validator_version="1",
|
|
44
|
+
producer_backend_image_digest="repo/image@sha256:" + "b" * 64,
|
|
45
|
+
retention_class="standard",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
dumped = ref.model_dump(mode="json")
|
|
49
|
+
|
|
50
|
+
assert dumped["schema_version"] == "validibot.artifact_ref.v1"
|
|
51
|
+
assert dumped["kind"] == "file"
|
|
52
|
+
assert dumped["producer_step_key"] == "build_model"
|
|
53
|
+
assert dumped["uri"].startswith("gs://")
|
|
54
|
+
assert dumped["storage_version"] == "1700000000000000"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("missing", ["size_bytes", "sha256", "storage_version", "uri"])
|
|
58
|
+
def test_artifact_ref_requires_complete_storage_identity(missing):
|
|
59
|
+
"""Artifact consumers must never receive a partial or mutable file pointer."""
|
|
60
|
+
payload = {
|
|
61
|
+
"artifact_id": "artifact-1",
|
|
62
|
+
"run_id": "run-1",
|
|
63
|
+
"step_run_id": "step-run-1",
|
|
64
|
+
"producer_step_key": "build_model",
|
|
65
|
+
"contract_key": "generated_model",
|
|
66
|
+
"name": "model.epjson",
|
|
67
|
+
"size_bytes": 1234,
|
|
68
|
+
"sha256": "a" * 64,
|
|
69
|
+
"storage_version": "1700000000000000",
|
|
70
|
+
"uri": "gs://bucket/runs/run-1/model.epjson",
|
|
71
|
+
}
|
|
72
|
+
payload.pop(missing)
|
|
73
|
+
|
|
74
|
+
with pytest.raises(ValidationError):
|
|
75
|
+
ArtifactRef(**payload)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_file_port_contract_serializes_shared_vocabulary():
|
|
79
|
+
"""File-port contracts should expose stable enum values to every repo."""
|
|
80
|
+
contract = FilePortContract(
|
|
81
|
+
contract_key="primary_model",
|
|
82
|
+
label="Primary Model",
|
|
83
|
+
direction=StepIODirection.INPUT,
|
|
84
|
+
io_medium=StepIOMedium.ARTIFACT,
|
|
85
|
+
envelope_channel=EnvelopeChannel.INPUT_FILES,
|
|
86
|
+
role="primary-model",
|
|
87
|
+
artifact_kind=ArtifactKind.FILE,
|
|
88
|
+
min_items=1,
|
|
89
|
+
max_items=1,
|
|
90
|
+
accepted_data_formats=["energyplus_idf", "energyplus_epjson"],
|
|
91
|
+
accepted_media_types=[
|
|
92
|
+
"application/vnd.energyplus.idf",
|
|
93
|
+
"application/vnd.energyplus.epjson",
|
|
94
|
+
],
|
|
95
|
+
allowed_source_scopes=[
|
|
96
|
+
BindingSourceScope.SUBMISSION_FILE,
|
|
97
|
+
BindingSourceScope.UPSTREAM_ARTIFACT,
|
|
98
|
+
BindingSourceScope.SIGNAL,
|
|
99
|
+
],
|
|
100
|
+
default_source_strategy=DefaultSourceStrategy.SUBMITTED_FILE_FIRST,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
dumped = contract.model_dump(mode="json")
|
|
104
|
+
|
|
105
|
+
assert dumped["contract_key"] == "primary_model"
|
|
106
|
+
assert dumped["io_medium"] == "artifact"
|
|
107
|
+
assert dumped["envelope_channel"] == "input_files"
|
|
108
|
+
assert dumped["allowed_source_scopes"] == [
|
|
109
|
+
"submission_file",
|
|
110
|
+
"upstream_artifact",
|
|
111
|
+
"signal",
|
|
112
|
+
]
|
|
113
|
+
assert dumped["default_source_strategy"] == "submitted_file_first"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_file_port_contract_rejects_extra_fields():
|
|
117
|
+
"""The contract should stay explicit rather than absorbing typos."""
|
|
118
|
+
with pytest.raises(ValidationError):
|
|
119
|
+
FilePortContract(
|
|
120
|
+
contract_key="primary_model",
|
|
121
|
+
direction=StepIODirection.INPUT,
|
|
122
|
+
envelope_channel=EnvelopeChannel.INPUT_FILES,
|
|
123
|
+
unknown=True,
|
|
124
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Tests for the strict callback nonce portion of the attempt contract.
|
|
2
|
+
|
|
3
|
+
Asynchronous validator callbacks need both delivery idempotency and proof that
|
|
4
|
+
the sender received the exact input envelope for one execution attempt. The
|
|
5
|
+
callback ID supplies the first property; the per-attempt nonce supplies the
|
|
6
|
+
second. These tests keep raw secrets out of model representations, require a
|
|
7
|
+
matching public commitment, preserve synchronous no-callback operation, and
|
|
8
|
+
reject the older attempt-v1 contract instead of silently accepting weaker
|
|
9
|
+
authentication.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from validibot_shared.canonicalization import compute_callback_nonce_commitment
|
|
18
|
+
from validibot_shared.validations.envelopes import (
|
|
19
|
+
ATTEMPT_CONTRACT_VERSION,
|
|
20
|
+
ExecutionContext,
|
|
21
|
+
ValidationCallback,
|
|
22
|
+
ValidationOutputEnvelope,
|
|
23
|
+
ValidationStatus,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CALLBACK_NONCE = "A" * 43
|
|
27
|
+
CALLBACK_COMMITMENT = compute_callback_nonce_commitment(CALLBACK_NONCE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _context_kwargs() -> dict[str, object]:
|
|
31
|
+
"""Return the non-secret fields required by one asynchronous attempt."""
|
|
32
|
+
return {
|
|
33
|
+
"execution_attempt_id": "attempt-1",
|
|
34
|
+
"step_run_id": "step-run-1",
|
|
35
|
+
"attempt_contract_version": ATTEMPT_CONTRACT_VERSION,
|
|
36
|
+
"expected_output_uri": "gs://bucket/attempt-1/output.json",
|
|
37
|
+
"callback_url": "https://example.com/callback",
|
|
38
|
+
"execution_bundle_uri": "gs://bucket/attempt-1/",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_async_context_requires_callback_id_and_nonce_pair() -> None:
|
|
43
|
+
"""An enabled callback must not validate with only public routing metadata."""
|
|
44
|
+
with pytest.raises(ValidationError, match="callback_id is required"):
|
|
45
|
+
ExecutionContext(**_context_kwargs())
|
|
46
|
+
|
|
47
|
+
with pytest.raises(ValidationError, match="required when callbacks are enabled"):
|
|
48
|
+
ExecutionContext(
|
|
49
|
+
**_context_kwargs(),
|
|
50
|
+
callback_id="execution-attempt-attempt-1",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_context_requires_nonce_and_commitment_together() -> None:
|
|
55
|
+
"""Neither half of the binding is meaningful without its matching partner."""
|
|
56
|
+
with pytest.raises(ValidationError, match="must be provided together"):
|
|
57
|
+
ExecutionContext(
|
|
58
|
+
**_context_kwargs(),
|
|
59
|
+
callback_id="execution-attempt-attempt-1",
|
|
60
|
+
callback_nonce=CALLBACK_NONCE,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_context_rejects_commitment_unrelated_to_nonce() -> None:
|
|
65
|
+
"""A mismatched commitment must fail before an envelope can be dispatched."""
|
|
66
|
+
with pytest.raises(ValidationError, match="does not match"):
|
|
67
|
+
ExecutionContext(
|
|
68
|
+
**_context_kwargs(),
|
|
69
|
+
callback_id="execution-attempt-attempt-1",
|
|
70
|
+
callback_nonce=CALLBACK_NONCE,
|
|
71
|
+
callback_nonce_commitment="0" * 64,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_async_context_accepts_matching_pair_without_displaying_secret() -> None:
|
|
76
|
+
"""A valid pair binds dispatch while keeping ordinary diagnostics redacted."""
|
|
77
|
+
context = ExecutionContext(
|
|
78
|
+
**_context_kwargs(),
|
|
79
|
+
callback_id="execution-attempt-attempt-1",
|
|
80
|
+
callback_nonce=CALLBACK_NONCE,
|
|
81
|
+
callback_nonce_commitment=CALLBACK_COMMITMENT,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert context.callback_nonce_commitment == CALLBACK_COMMITMENT
|
|
85
|
+
assert CALLBACK_NONCE not in repr(context)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_sync_context_does_not_require_an_unused_callback_secret() -> None:
|
|
89
|
+
"""Local synchronous execution remains valid when callback delivery is skipped."""
|
|
90
|
+
context = ExecutionContext(
|
|
91
|
+
**_context_kwargs(),
|
|
92
|
+
callback_id="execution-attempt-attempt-1",
|
|
93
|
+
skip_callback=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
assert context.callback_nonce is None
|
|
97
|
+
assert context.callback_nonce_commitment is None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_attempt_v1_is_rejected_after_the_strict_contract_cutover() -> None:
|
|
101
|
+
"""Mixed runtime versions must fail explicitly instead of losing nonce fields."""
|
|
102
|
+
with pytest.raises(ValidationError, match="validibot.attempt.v2"):
|
|
103
|
+
ExecutionContext(
|
|
104
|
+
**(
|
|
105
|
+
_context_kwargs() | {"attempt_contract_version": "validibot.attempt.v1"}
|
|
106
|
+
),
|
|
107
|
+
callback_id="execution-attempt-attempt-1",
|
|
108
|
+
callback_nonce=CALLBACK_NONCE,
|
|
109
|
+
callback_nonce_commitment=CALLBACK_COMMITMENT,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_callback_payload_transports_secret_without_displaying_it() -> None:
|
|
114
|
+
"""The backend must send the nonce, while ordinary repr output stays redacted."""
|
|
115
|
+
callback = ValidationCallback(
|
|
116
|
+
run_id="run-1",
|
|
117
|
+
callback_id="execution-attempt-attempt-1",
|
|
118
|
+
callback_nonce=CALLBACK_NONCE,
|
|
119
|
+
status=ValidationStatus.SUCCESS,
|
|
120
|
+
result_uri="gs://bucket/attempt-1/output.json",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
assert callback.model_dump()["callback_nonce"] == CALLBACK_NONCE
|
|
124
|
+
assert CALLBACK_NONCE not in repr(callback)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_callback_payload_rejects_missing_or_short_nonce() -> None:
|
|
128
|
+
"""A callback without a full-strength attempt secret is not authenticated."""
|
|
129
|
+
base = {
|
|
130
|
+
"run_id": "run-1",
|
|
131
|
+
"callback_id": "execution-attempt-attempt-1",
|
|
132
|
+
"status": ValidationStatus.SUCCESS,
|
|
133
|
+
"result_uri": "gs://bucket/attempt-1/output.json",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
with pytest.raises(ValidationError, match="callback_nonce"):
|
|
137
|
+
ValidationCallback(**base)
|
|
138
|
+
with pytest.raises(ValidationError, match="at least 43 characters"):
|
|
139
|
+
ValidationCallback(**base, callback_nonce="too-short")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_output_envelope_has_no_callback_secret_field() -> None:
|
|
143
|
+
"""Public results must never expose the live secret used for callback auth."""
|
|
144
|
+
assert "callback_nonce" not in ValidationOutputEnvelope.model_fields
|