validibot-shared 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,70 @@
1
+ """
2
+ FMI probe result models.
3
+
4
+ These models define the contract for FMU probing operations - extracting
5
+ metadata from modelDescription.xml to populate validator catalog entries.
6
+
7
+ Probing is done in-process in the Django worker (not in containers) since
8
+ it's just XML parsing with no FMU execution.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class FMIVariableMeta(BaseModel):
19
+ """Metadata extracted from modelDescription.xml."""
20
+
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ name: str
24
+ causality: str
25
+ variability: str | None = None
26
+ value_reference: int = 0
27
+ value_type: str
28
+ unit: str | None = None
29
+
30
+
31
+ class FMIProbeResult(BaseModel):
32
+ """Result of a short probe run to vet an FMU before approval."""
33
+
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ status: Literal["success", "error"] = "error"
37
+ variables: list[FMIVariableMeta] = Field(default_factory=list)
38
+ errors: list[str] = Field(default_factory=list)
39
+ messages: list[str] = Field(default_factory=list)
40
+ execution_seconds: float | None = None
41
+
42
+ @classmethod
43
+ def success(
44
+ cls,
45
+ *,
46
+ variables: list[FMIVariableMeta],
47
+ execution_seconds: float | None = None,
48
+ messages: list[str] | None = None,
49
+ ) -> FMIProbeResult:
50
+ return cls(
51
+ status="success",
52
+ variables=variables,
53
+ execution_seconds=execution_seconds,
54
+ messages=messages or [],
55
+ errors=[],
56
+ )
57
+
58
+ @classmethod
59
+ def failure(
60
+ cls,
61
+ *,
62
+ errors: list[str],
63
+ messages: list[str] | None = None,
64
+ ) -> FMIProbeResult:
65
+ return cls(
66
+ status="error",
67
+ variables=[],
68
+ errors=errors,
69
+ messages=messages or [],
70
+ )
File without changes
File without changes
@@ -0,0 +1,607 @@
1
+ """
2
+ Pydantic schemas for Advanced validator job execution envelopes.
3
+
4
+ These schemas define the contract between Django and Advanced validator
5
+ containers/services. Advanced validators are validations that run in separate
6
+ containers or remote services (e.g., EnergyPlus, FMI), as opposed to Simple
7
+ validators that run directly within Django.
8
+
9
+ This library is used by both the Django app and Advanced validator services to ensure
10
+ type safety and contract consistency across the network boundary.
11
+
12
+ ## Deployment Modes
13
+
14
+ This module supports multiple deployment modes:
15
+
16
+ 1. **GCP deployment**: Uses gs:// URIs for Google Cloud Storage
17
+ 2. **Self-hosted Docker** (default): Uses file:// URIs for local filesystem
18
+
19
+ The envelope schemas are storage-agnostic - they accept any valid URI. The
20
+ actual storage handling is done by the validators' storage_client module.
21
+
22
+ ## Architecture Overview
23
+
24
+ This module provides a type-safe interface for communication between the Django
25
+ app and Advanced validator containers:
26
+
27
+ **GCP Mode (async with callbacks):**
28
+ 1. Django creates input.json with files, config, callback URL
29
+ 2. Django uploads to GCS and triggers validator container
30
+ 3. Validator downloads inputs, runs validation, uploads outputs
31
+ 4. Validator POSTs minimal callback to Django when complete
32
+ 5. Django receives callback and loads full output.json from GCS
33
+
34
+ **Self-hosted Mode (sync execution):**
35
+ 1. Django creates input.json with files, config
36
+ 2. Django uploads to local storage and runs Docker container synchronously
37
+ 3. Validator reads inputs, runs validation, writes outputs to local storage
38
+ 4. Docker container exits, Django reads output.json directly
39
+
40
+ ## Why Two Separate Fields: input_files vs inputs?
41
+
42
+ - **input_files**: Generic list of GCS file URIs (IDF, EPW, FMU, XML, etc.)
43
+ - NEVER changes in subclasses - all validators need file inputs
44
+ - Role field distinguishes file purposes (e.g., 'primary-model' vs 'weather')
45
+
46
+ - **inputs**: Domain-specific configuration parameters
47
+ - Base class uses dict[str, Any] for flexibility
48
+ - Subclasses override with typed Pydantic models (e.g., EnergyPlusInputs)
49
+ - Examples: timestep settings, output variables, simulation options
50
+
51
+ This separation keeps file handling generic while allowing type-safe config.
52
+
53
+ ## Why Separate outputs Field?
54
+
55
+ - **messages/metrics/artifacts**: Generic outputs (errors, warnings, values)
56
+ - Available on all validators for consistent reporting
57
+
58
+ - **outputs**: Domain-specific detailed results
59
+ - Base class uses dict[str, Any] | None for flexibility
60
+ - Subclasses override with typed models (e.g., EnergyPlusOutputs
61
+ with returncode, logs, file paths)
62
+ - Optional because not all validators produce detailed domain-specific data
63
+
64
+ ## Subclassing Pattern
65
+
66
+ Domain-specific validators (energyplus, fmi, xml) create typed subclasses:
67
+
68
+ ```python
69
+ # In energyplus/envelopes.py
70
+ class EnergyPlusInputs(BaseModel):
71
+ timestep_per_hour: int = 4
72
+ invocation_mode: Literal["python_api", "cli"] = "cli"
73
+ # ... other EnergyPlus-specific config
74
+
75
+ class EnergyPlusInputEnvelope(ValidationInputEnvelope):
76
+ inputs: EnergyPlusInputs # Override dict[str, Any] with typed version!
77
+
78
+ class EnergyPlusOutputs(BaseModel):
79
+ energyplus_returncode: int
80
+ execution_seconds: float
81
+ # ... other EnergyPlus-specific results
82
+
83
+ class EnergyPlusOutputEnvelope(ValidationOutputEnvelope):
84
+ outputs: EnergyPlusOutputs # Override dict[str, Any] with typed version!
85
+ ```
86
+
87
+ Django deserializes using the correct subclass based on validator.type.
88
+
89
+ ## Why ValidationCallback?
90
+
91
+ The callback is a minimal async notification sent from the validator container
92
+ back to the Django app when work completes. It contains only:
93
+ - run_id: Which job finished
94
+ - status: success/failed_validation/failed_runtime/cancelled
95
+ - result_uri: Storage path to full output.json
96
+
97
+ This avoids redundant data transfer since the full output.json is already in storage.
98
+ The callback enables async execution (validator POSTs when done) instead of the
99
+ Django app having to poll job status repeatedly.
100
+
101
+ See docs/adr/2025-12-04-validator-job-interface.md in the validibot repository
102
+ for the full specification.
103
+ """
104
+
105
+ from __future__ import annotations
106
+
107
+ from datetime import datetime # noqa: TC003
108
+ from enum import Enum
109
+ from typing import Any, Literal
110
+
111
+ from pydantic import BaseModel, Field, HttpUrl
112
+
113
+ # ==============================================================================
114
+ # Shared Enums
115
+ # ==============================================================================
116
+
117
+
118
+ class Severity(str, Enum):
119
+ """Severity level for validation messages."""
120
+
121
+ INFO = "INFO"
122
+ WARNING = "WARNING"
123
+ ERROR = "ERROR"
124
+
125
+
126
+ class ValidatorType(str, Enum):
127
+ """
128
+ Canonical validator types used across Django and validator containers.
129
+
130
+ These values align with Django's ValidationType TextChoices and should be
131
+ used anywhere we serialize validator identifiers in envelopes.
132
+ """
133
+
134
+ BASIC = "BASIC"
135
+ JSON_SCHEMA = "JSON_SCHEMA"
136
+ XML_SCHEMA = "XML_SCHEMA"
137
+ ENERGYPLUS = "ENERGYPLUS"
138
+ FMI = "FMI"
139
+ CUSTOM_VALIDATOR = "CUSTOM_VALIDATOR"
140
+ AI_ASSIST = "AI_ASSIST"
141
+
142
+
143
+ # ==============================================================================
144
+ # Input Envelope (validibot.input.v1)
145
+ # ==============================================================================
146
+
147
+
148
+ class SupportedMimeType(str, Enum):
149
+ """
150
+ Supported MIME types for file inputs.
151
+
152
+ We only accept specific file types that our validators know how to process.
153
+ """
154
+
155
+ # XML documents
156
+ APPLICATION_XML = "application/xml"
157
+ TEXT_XML = "text/xml"
158
+
159
+ # EnergyPlus files
160
+ ENERGYPLUS_IDF = "application/vnd.energyplus.idf" # IDF text format
161
+ ENERGYPLUS_EPJSON = "application/vnd.energyplus.epjson" # epJSON format
162
+ ENERGYPLUS_EPW = "application/vnd.energyplus.epw" # Weather data
163
+
164
+ # FMU files
165
+ FMU = "application/vnd.fmi.fmu" # Functional Mock-up Unit
166
+
167
+
168
+ class InputFileItem(BaseModel):
169
+ """
170
+ A file input for the validator.
171
+
172
+ Files are stored in GCS and referenced by URI. The 'role' field allows
173
+ validators to understand what each file is for (e.g., 'primary-model' vs
174
+ 'weather' for EnergyPlus).
175
+ """
176
+
177
+ name: str = Field(description="Human-readable name of the file")
178
+
179
+ mime_type: SupportedMimeType = Field(description="MIME type of the file")
180
+
181
+ role: str | None = Field(
182
+ default=None,
183
+ description=(
184
+ "Validator-specific role (e.g., 'primary-model', 'weather', 'config')"
185
+ ),
186
+ )
187
+
188
+ uri: str = Field(
189
+ description="Storage URI to the file (gs:// or file:// for self-hosted)"
190
+ )
191
+
192
+ model_config = {"extra": "forbid"}
193
+
194
+
195
+ class ValidatorInfo(BaseModel):
196
+ """
197
+ Information about the validator being executed.
198
+
199
+ This identifies which validator container to run and which version.
200
+ The 'type' field determines:
201
+ 1. Which validator container to run (e.g., 'validibot-validator-energyplus')
202
+ 2. Which envelope subclass Django uses for deserialization
203
+ (EnergyPlusInputEnvelope, FMIInputEnvelope, etc.)
204
+
205
+ This class appears in both input and output envelopes to maintain traceability
206
+ of which validator version produced which results.
207
+ """
208
+
209
+ id: str = Field(description="Validator UUID from Django database")
210
+
211
+ type: ValidatorType = Field(
212
+ description="Validator type (e.g., 'ENERGYPLUS', 'FMI', 'JSON_SCHEMA')"
213
+ )
214
+
215
+ version: str = Field(description="Validator version (e.g., '1.0.0')")
216
+
217
+ model_config = {"extra": "forbid"}
218
+
219
+
220
+ class OrganizationInfo(BaseModel):
221
+ """
222
+ Information about the organization running the validation.
223
+
224
+ Included for logging, debugging, and future multi-tenancy features.
225
+ The name is redundant with ID but helpful for human-readable logs.
226
+ """
227
+
228
+ id: str = Field(description="Organization UUID from Django database")
229
+
230
+ name: str = Field(description="Organization name (for human-readable logs)")
231
+
232
+ model_config = {"extra": "forbid"}
233
+
234
+
235
+ class WorkflowInfo(BaseModel):
236
+ """
237
+ Information about the workflow and step being executed.
238
+
239
+ Validators are executed as steps within larger workflows. This metadata
240
+ enables tracing validation results back to their workflow context for:
241
+ - Debugging (which workflow triggered this validation?)
242
+ - Auditing (track all validations in a workflow)
243
+ - UI display (show validation status within workflow visualization)
244
+ """
245
+
246
+ id: str = Field(description="Workflow UUID")
247
+
248
+ step_id: str = Field(description="Workflow step UUID")
249
+
250
+ step_name: str | None = Field(default=None, description="Human-readable step name")
251
+
252
+ model_config = {"extra": "forbid"}
253
+
254
+
255
+ class ExecutionContext(BaseModel):
256
+ """
257
+ Execution context and callback information.
258
+
259
+ This provides the validator container with everything it needs to:
260
+ 1. Download input files from storage (execution_bundle_uri)
261
+ 2. Upload output files to storage (execution_bundle_uri)
262
+ 3. Notify Django when complete (callback_url)
263
+ 4. Respect timeout constraints (timeout_seconds)
264
+ """
265
+
266
+ callback_id: str | None = Field(
267
+ default=None,
268
+ description=(
269
+ "Unique identifier for this callback, used for idempotency. "
270
+ "Generated at job launch and echoed back in the callback payload. "
271
+ "The callback handler uses this to detect and ignore duplicate deliveries."
272
+ ),
273
+ )
274
+
275
+ callback_url: HttpUrl | None = Field(
276
+ default=None,
277
+ description="URL to POST callback when validation completes",
278
+ )
279
+
280
+ skip_callback: bool = Field(
281
+ default=False,
282
+ description=(
283
+ "If True, skip POSTing callback to callback_url after completion. "
284
+ "Useful for testing where polling GCS for output.json is preferred."
285
+ ),
286
+ )
287
+
288
+ execution_bundle_uri: str = Field(
289
+ description="Storage URI to the execution bundle directory (gs:// or file://)"
290
+ )
291
+
292
+ timeout_seconds: int = Field(
293
+ default=3600, description="Maximum execution time in seconds"
294
+ )
295
+
296
+ tags: list[str] = Field(default_factory=list, description="Execution tags")
297
+
298
+ model_config = {"extra": "forbid"}
299
+
300
+
301
+ class ValidationInputEnvelope(BaseModel):
302
+ """
303
+ Base input envelope for validator jobs (validibot.input.v1).
304
+
305
+ This is written to storage as input.json by Django before triggering
306
+ the validator container.
307
+
308
+ ## How Subclassing Works
309
+
310
+ Domain-specific validators create subclasses that override the 'inputs' field
311
+ with a typed Pydantic model. The base class uses dict[str, Any] for flexibility,
312
+ but subclasses provide type safety:
313
+
314
+ ```python
315
+ class EnergyPlusInputEnvelope(ValidationInputEnvelope):
316
+ inputs: EnergyPlusInputs # Typed override!
317
+ ```
318
+
319
+ This pattern allows:
320
+ - Generic base class for all validators (this class)
321
+ - Type-safe domain-specific subclasses (EnergyPlusInputEnvelope,
322
+ FMIInputEnvelope, etc.)
323
+ - Django to serialize/deserialize using the correct subclass based on
324
+ validator.type
325
+
326
+ ## Fields That Never Change in Subclasses
327
+
328
+ - input_files: All validators receive files the same way
329
+ - context: All validators use the same execution context
330
+ - validator/org/workflow: All validators need this metadata
331
+
332
+ ## Fields That Subclasses Override
333
+
334
+ - inputs: Domain-specific configuration (dict[str, Any] → EnergyPlusInputs, etc.)
335
+ """
336
+
337
+ schema_version: Literal["validibot.input.v1"] = "validibot.input.v1"
338
+
339
+ run_id: str = Field(description="Unique run identifier (UUID)")
340
+
341
+ validator: ValidatorInfo
342
+
343
+ org: OrganizationInfo
344
+
345
+ workflow: WorkflowInfo
346
+
347
+ input_files: list[InputFileItem] = Field(
348
+ default_factory=list,
349
+ description="File inputs for the validator (GCS URIs with roles)",
350
+ )
351
+
352
+ inputs: dict[str, Any] = Field(
353
+ default_factory=dict,
354
+ description=("Domain-specific inputs (subclasses override with typed model)"),
355
+ )
356
+
357
+ context: ExecutionContext
358
+
359
+ model_config = {"extra": "forbid"}
360
+
361
+
362
+ # ==============================================================================
363
+ # Output Envelope (validibot.output.v1)
364
+ # ==============================================================================
365
+
366
+
367
+ class ValidationStatus(str, Enum):
368
+ """Validation output status."""
369
+
370
+ SUCCESS = "success" # Validation completed successfully, no errors
371
+ FAILED_VALIDATION = "failed_validation" # Validation found errors (user's fault)
372
+ FAILED_RUNTIME = "failed_runtime" # Runtime error in validator (system fault)
373
+ CANCELLED = "cancelled" # User or system cancelled the job
374
+
375
+
376
+ class MessageLocation(BaseModel):
377
+ """Location information for a validation message."""
378
+
379
+ file_role: str | None = Field(
380
+ default=None, description="Input file role (references InputFileItem.role)"
381
+ )
382
+ line: int | None = Field(default=None, description="Line number")
383
+ column: int | None = Field(default=None, description="Column number")
384
+ path: str | None = Field(
385
+ default=None, description="Object path or XPath-like identifier"
386
+ )
387
+
388
+ model_config = {"extra": "forbid"}
389
+
390
+
391
+ class ValidationMessage(BaseModel):
392
+ """A validation finding, warning, or error."""
393
+
394
+ severity: Severity
395
+
396
+ code: str | None = Field(
397
+ default=None, description="Error code (e.g., 'EP001', 'FMU_INIT_ERROR')"
398
+ )
399
+
400
+ text: str = Field(description="Human-readable message")
401
+
402
+ location: MessageLocation | None = Field(
403
+ default=None, description="Location of the issue"
404
+ )
405
+
406
+ tags: list[str] = Field(default_factory=list, description="Message tags/categories")
407
+
408
+ model_config = {"extra": "forbid"}
409
+
410
+
411
+ class ValidationMetric(BaseModel):
412
+ """A computed metric from the validation/simulation."""
413
+
414
+ name: str = Field(description="Metric name (e.g., 'zone_temp_max')")
415
+
416
+ value: float | int | str = Field(description="Metric value")
417
+
418
+ unit: str | None = Field(default=None, description="Unit (e.g., 'C', 'kWh', 'm2')")
419
+
420
+ category: str | None = Field(
421
+ default=None, description="Category (e.g., 'comfort', 'energy', 'performance')"
422
+ )
423
+
424
+ tags: list[str] = Field(default_factory=list, description="Metric tags")
425
+
426
+ model_config = {"extra": "forbid"}
427
+
428
+
429
+ class ValidationArtifact(BaseModel):
430
+ """A file artifact produced by the validator."""
431
+
432
+ name: str = Field(description="Artifact name")
433
+
434
+ type: str = Field(
435
+ description=(
436
+ "Artifact type (e.g., 'simulation-db', 'report-html', 'timeseries-csv')"
437
+ )
438
+ )
439
+
440
+ mime_type: str | None = Field(
441
+ default=None, description="MIME type (e.g., 'application/x-sqlite3')"
442
+ )
443
+
444
+ uri: str = Field(
445
+ description="Storage URI to the artifact (gs:// or file:// for self-hosted)"
446
+ )
447
+
448
+ size_bytes: int | None = Field(default=None, description="File size in bytes")
449
+
450
+ model_config = {"extra": "forbid"}
451
+
452
+
453
+ class RawOutputs(BaseModel):
454
+ """Information about raw output files."""
455
+
456
+ format: Literal["directory", "archive"] = Field(
457
+ description="Format of raw outputs (directory or archive)"
458
+ )
459
+
460
+ manifest_uri: str = Field(
461
+ description="Storage URI to the manifest file (gs:// or file://)"
462
+ )
463
+
464
+ model_config = {"extra": "forbid"}
465
+
466
+
467
+ class ValidationTiming(BaseModel):
468
+ """Timing information for the validation run."""
469
+
470
+ queued_at: datetime | None = Field(
471
+ default=None, description="When the job was queued (ISO8601)"
472
+ )
473
+
474
+ started_at: datetime | None = Field(
475
+ default=None, description="When execution started (ISO8601)"
476
+ )
477
+
478
+ finished_at: datetime | None = Field(
479
+ default=None, description="When execution finished (ISO8601)"
480
+ )
481
+
482
+ model_config = {"extra": "forbid"}
483
+
484
+
485
+ class ValidationOutputEnvelope(BaseModel):
486
+ """
487
+ Base output envelope for validator jobs (validibot.output.v1).
488
+
489
+ This is written to storage as output.json by the validator container
490
+ after completion.
491
+
492
+ ## How Subclassing Works
493
+
494
+ Domain-specific validators create subclasses that override the 'outputs' field
495
+ with a typed Pydantic model containing detailed domain-specific results:
496
+
497
+ ```python
498
+ class EnergyPlusOutputEnvelope(ValidationOutputEnvelope):
499
+ outputs: EnergyPlusOutputs # Typed override!
500
+ ```
501
+
502
+ This pattern allows:
503
+ - Generic base class for all validators (this class)
504
+ - Type-safe domain-specific subclasses (EnergyPlusOutputEnvelope,
505
+ FMIOutputEnvelope, etc.)
506
+ - Django to deserialize using the correct subclass based on validator.type
507
+
508
+ ## Generic vs Domain-Specific Outputs
509
+
510
+ All validators populate these **generic** fields:
511
+ - status: SUCCESS/FAILED_VALIDATION/FAILED_RUNTIME/CANCELLED
512
+ - messages: Errors, warnings, info (consistent across all validators)
513
+ - metrics: Computed values like energy use, temperature ranges
514
+ - artifacts: GCS URIs to output files (SQL, CSV reports, HTML visualizations)
515
+ - timing: When the job was queued/started/finished
516
+
517
+ Some validators also populate **domain-specific** outputs:
518
+ - outputs: Detailed execution data (returncode, logs, file paths, etc.)
519
+ - Optional because not all validators produce this level of detail
520
+
521
+ ## Why Both metrics and outputs?
522
+
523
+ - **metrics**: High-level computed values for UI display
524
+ (energy use, peak temperature, etc.)
525
+ - Always a flat list of name/value/unit tuples
526
+ - Consistent format across all validators
527
+ - Used for dashboards, comparisons, search
528
+
529
+ - **outputs**: Detailed domain-specific execution data
530
+ - Nested structure with validator-specific fields
531
+ - Examples: EnergyPlus returncode, FMU logs, XML schema validation
532
+ - Used for debugging, detailed analysis, reproducing results
533
+
534
+ ## Why raw_outputs?
535
+
536
+ Some validators produce many output files (EnergyPlus can create dozens).
537
+ Instead of creating a ValidationArtifact for each one, we:
538
+ 1. Upload all files to GCS
539
+ 2. Create a manifest.json listing all files
540
+ 3. Set raw_outputs.manifest_uri to point to the manifest
541
+ This keeps the envelope small while preserving access to all files.
542
+ """
543
+
544
+ schema_version: Literal["validibot.output.v1"] = "validibot.output.v1"
545
+
546
+ run_id: str = Field(description="Unique run identifier (matches input)")
547
+
548
+ validator: ValidatorInfo
549
+
550
+ status: ValidationStatus
551
+
552
+ timing: ValidationTiming
553
+
554
+ messages: list[ValidationMessage] = Field(
555
+ default_factory=list, description="Validation findings, warnings, errors"
556
+ )
557
+
558
+ metrics: list[ValidationMetric] = Field(
559
+ default_factory=list, description="Computed metrics from the validation"
560
+ )
561
+
562
+ artifacts: list[ValidationArtifact] = Field(
563
+ default_factory=list, description="Output files produced by the validator"
564
+ )
565
+
566
+ raw_outputs: RawOutputs | None = Field(
567
+ default=None, description="Raw output files information"
568
+ )
569
+
570
+ outputs: dict[str, Any] | None = Field(
571
+ default=None,
572
+ description=("Domain-specific outputs (subclasses override with typed model)"),
573
+ )
574
+
575
+ model_config = {"extra": "forbid"}
576
+
577
+
578
+ # ==============================================================================
579
+ # Callback Payload
580
+ # ==============================================================================
581
+
582
+
583
+ class ValidationCallback(BaseModel):
584
+ """
585
+ Callback payload POSTed from validator container to Django.
586
+
587
+ Minimal payload to avoid duplication - Django loads the full output.json
588
+ from storage after receiving this callback.
589
+ """
590
+
591
+ run_id: str = Field(description="Run identifier")
592
+
593
+ callback_id: str | None = Field(
594
+ default=None,
595
+ description=(
596
+ "Idempotency key echoed from the input envelope's context.callback_id. "
597
+ "Used by the callback handler to detect and ignore duplicate deliveries."
598
+ ),
599
+ )
600
+
601
+ status: ValidationStatus
602
+
603
+ result_uri: str = Field(
604
+ description="Storage URI to output.json (gs:// or file:// for self-hosted)"
605
+ )
606
+
607
+ model_config = {"extra": "forbid"}