roborean-spec 0.1.2__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.
@@ -0,0 +1,27 @@
1
+ venv/
2
+ venv-qt5/
3
+ venv-qt6/
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .coverage
10
+ htmlcov/
11
+ dist/
12
+ build/
13
+ *.egg-info/
14
+ *.tsbuildinfo
15
+ node_modules/
16
+ .pnpm-store/
17
+ .DS_Store
18
+ .idea/
19
+ .vscode/*.local
20
+ playground/
21
+ .e2e-ai/
22
+ **/test-results/
23
+ **/playwright-report/
24
+ research/
25
+ .roborean/
26
+ .roborean-sql-artifacts/
27
+ *.db
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+
7
+ - Schema-backed Pydantic models and project migration support for Roborean
8
+ projects.
9
+ - Durable-run models: `RunRequest`, `RunRecord`, `RunDiff`, and related
10
+ enums for Phase 2 persistence.
11
+ - Document models for Phase 3: extended `DocumentDefinition`,
12
+ `TemplateManifest`, `DocumentOperation`, `ArtifactRecord`, and project
13
+ format `1.1.0`.
14
+
15
+ ### Changed
16
+
17
+ - Export `Redacted` workspace value from the public package API.
18
+
19
+ ### Fixed
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: roborean-spec
3
+ Version: 0.1.2
4
+ Summary: Schema-backed Roborean project models
5
+ Project-URL: Homepage, https://github.com/TNick/roborean
6
+ Project-URL: Repository, https://github.com/TNick/roborean
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: jsonschema>=4.23
10
+ Requires-Dist: pydantic>=2.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: black>=24.0; extra == 'dev'
13
+ Requires-Dist: flake8>=7.0; extra == 'dev'
14
+ Requires-Dist: isort>=5.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "roborean-spec"
7
+ version = "0.1.2"
8
+ description = "Schema-backed Roborean project models"
9
+ requires-python = ">=3.11"
10
+ license = "MIT"
11
+ dependencies = [
12
+ "jsonschema>=4.23",
13
+ "pydantic>=2.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["pytest>=8.0", "black>=24.0", "isort>=5.0", "flake8>=7.0"]
18
+
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/TNick/roborean"
22
+ Repository = "https://github.com/TNick/roborean"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/roborean_spec"]
@@ -0,0 +1,107 @@
1
+ """Schema-backed models and migration helpers for Roborean."""
2
+
3
+ from typing import Any
4
+
5
+ from .migrations import migrate_project
6
+ from .models import (
7
+ ArtifactRecord,
8
+ Bit,
9
+ BitResult,
10
+ BitTypeManifest,
11
+ CompiledProject,
12
+ DocumentDefinition,
13
+ DocumentDriverManifest,
14
+ DocumentOperation,
15
+ DocumentPreview,
16
+ DocumentPreviewSettings,
17
+ EffectClass,
18
+ Exposure,
19
+ OnError,
20
+ Project,
21
+ PublicLiteral,
22
+ Redacted,
23
+ RejectOp,
24
+ RuleAst,
25
+ RunDiff,
26
+ RunError,
27
+ RunRecord,
28
+ RunRequest,
29
+ RunResults,
30
+ RunStatus,
31
+ RunTrigger,
32
+ SecretRefAccess,
33
+ SecretRefValue,
34
+ SetOp,
35
+ TemplateManifest,
36
+ TemplateSlot,
37
+ UnsetOp,
38
+ Variable,
39
+ WorkspaceChange,
40
+ WorkspacePatch,
41
+ WorkspaceValue,
42
+ )
43
+ from .schema_loader import (
44
+ find_repo_root,
45
+ load_schema,
46
+ schema_dir,
47
+ validate_instance,
48
+ )
49
+
50
+ __version__ = "0.3.0"
51
+
52
+
53
+ def project_from_dict(data: dict[str, Any]) -> Project:
54
+ """Validate and construct a project model from JSON-compatible data."""
55
+ validate_instance("project", data)
56
+ return Project.model_validate(data)
57
+
58
+
59
+ def project_to_dict(project: Project) -> dict[str, Any]:
60
+ """Return a JSON-compatible, schema-shaped project dictionary."""
61
+ return project.model_dump(mode="json", by_alias=True, exclude_none=True)
62
+
63
+
64
+ __all__ = [
65
+ "ArtifactRecord",
66
+ "Bit",
67
+ "BitResult",
68
+ "BitTypeManifest",
69
+ "CompiledProject",
70
+ "DocumentDefinition",
71
+ "DocumentDriverManifest",
72
+ "DocumentOperation",
73
+ "DocumentPreview",
74
+ "DocumentPreviewSettings",
75
+ "EffectClass",
76
+ "Exposure",
77
+ "OnError",
78
+ "Project",
79
+ "PublicLiteral",
80
+ "Redacted",
81
+ "RejectOp",
82
+ "RuleAst",
83
+ "RunDiff",
84
+ "RunError",
85
+ "RunRecord",
86
+ "RunRequest",
87
+ "RunResults",
88
+ "RunStatus",
89
+ "RunTrigger",
90
+ "SecretRefAccess",
91
+ "SecretRefValue",
92
+ "SetOp",
93
+ "TemplateManifest",
94
+ "TemplateSlot",
95
+ "UnsetOp",
96
+ "Variable",
97
+ "WorkspaceChange",
98
+ "WorkspacePatch",
99
+ "WorkspaceValue",
100
+ "find_repo_root",
101
+ "load_schema",
102
+ "migrate_project",
103
+ "project_from_dict",
104
+ "project_to_dict",
105
+ "schema_dir",
106
+ "validate_instance",
107
+ ]
@@ -0,0 +1,5 @@
1
+ """Roborean project format migrations."""
2
+
3
+ from .registry import migrate_project
4
+
5
+ __all__ = ["migrate_project"]
@@ -0,0 +1,24 @@
1
+ """Registry for explicit Roborean project format migrations."""
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+
7
+ def migrate_project(
8
+ data: dict[str, Any],
9
+ target: str = "1.1.0",
10
+ ) -> dict[str, Any]:
11
+ """Migrate a project dictionary to a supported target version."""
12
+ result = deepcopy(data)
13
+ source = result.get("schemaVersion")
14
+ if source == target:
15
+ return result
16
+ if source == "1.0.0" and target == "1.1.0":
17
+ # Document fields are additive; bump the version marker only.
18
+ result["schemaVersion"] = "1.1.0"
19
+ return result
20
+ if source == "1.0.0" and target == "1.0.0":
21
+ return result
22
+ raise ValueError(
23
+ f"Unsupported project migration from {source!r} to {target!r}"
24
+ )
@@ -0,0 +1,489 @@
1
+ """Pydantic models for the canonical Roborean project format."""
2
+
3
+ from enum import Enum
4
+ from typing import Annotated, Any, Literal, Union
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
7
+
8
+
9
+ class Model(BaseModel):
10
+ """Base model that mirrors schemas without extra properties."""
11
+
12
+ model_config = ConfigDict(extra="forbid")
13
+
14
+
15
+ class PublicLiteral(Model):
16
+ """A public literal workspace value."""
17
+
18
+ kind: Literal["public_literal"]
19
+ data_type: Literal["string", "number", "boolean", "date"] = Field(
20
+ alias="dataType"
21
+ )
22
+ value: Any
23
+
24
+
25
+ class SecretRefValue(Model):
26
+ """A reference to a secret held outside the portable project."""
27
+
28
+ kind: Literal["secret_ref"]
29
+ ref: str
30
+ display_hint: str | None = Field(default=None, alias="displayHint")
31
+
32
+
33
+ class EqToken(Model):
34
+ """An equality-preserving opaque token."""
35
+
36
+ kind: Literal["eq_token"]
37
+ token: str
38
+ domain: str | None = None
39
+
40
+
41
+ class ShapeToken(Model):
42
+ """A value represented by its structural shape."""
43
+
44
+ kind: Literal["shape_token"]
45
+ shape: Literal["email", "phone", "iban", "uuid", "code"]
46
+ length: int | None = None
47
+
48
+
49
+ class Bucket(Model):
50
+ """A bucketed value with optional numeric bounds."""
51
+
52
+ kind: Literal["bucket"]
53
+ bucket: str
54
+ bounds: tuple[float, float] | None = None
55
+
56
+
57
+ class Redacted(Model):
58
+ """A value withheld by secret or policy rules."""
59
+
60
+ kind: Literal["redacted"]
61
+ reason: Literal["secret", "policy", "consent", "unknown"]
62
+
63
+
64
+ WorkspaceValue = Annotated[
65
+ Union[
66
+ PublicLiteral,
67
+ SecretRefValue,
68
+ EqToken,
69
+ ShapeToken,
70
+ Bucket,
71
+ Redacted,
72
+ ],
73
+ Field(discriminator="kind"),
74
+ ]
75
+
76
+
77
+ class Exposure(str, Enum):
78
+ """Controls whether a workspace value can reach clients."""
79
+
80
+ BACKEND_ONLY = "backendOnly"
81
+ REDACTED_TO_CLIENT = "redactedToClient"
82
+ CLIENT_VISIBLE = "clientVisible"
83
+
84
+
85
+ class Variable(Model):
86
+ """A declared workspace variable."""
87
+
88
+ key: str
89
+ schema_: dict[str, Any] = Field(alias="schema")
90
+ default_value: WorkspaceValue = Field(alias="defaultValue")
91
+ const: bool = False
92
+ exposure: Exposure
93
+ description: str | None = None
94
+
95
+
96
+ class RuleAst(Model):
97
+ """A recursive CEL-profile rule expression."""
98
+
99
+ op: Literal[
100
+ "and",
101
+ "or",
102
+ "not",
103
+ "eq",
104
+ "ne",
105
+ "lt",
106
+ "le",
107
+ "gt",
108
+ "ge",
109
+ "has",
110
+ "const",
111
+ "var",
112
+ ]
113
+ args: list[Any]
114
+
115
+
116
+ class EffectClass(str, Enum):
117
+ """Classifies a bit's execution effects."""
118
+
119
+ PURE = "pure"
120
+ WORKSPACE = "workspace"
121
+ DOCUMENT = "document"
122
+ FILESYSTEM = "filesystem"
123
+ NETWORK = "network"
124
+ EXTERNAL_PROCESS = "external-process"
125
+ TRANSACTIONAL_EXTERNAL = "transactional-external"
126
+
127
+
128
+ class OnError(str, Enum):
129
+ """Controls a run after a failed bit."""
130
+
131
+ ABORT = "abort"
132
+ SKIP = "skip"
133
+ CONTINUE = "continue"
134
+
135
+
136
+ class Bit(Model):
137
+ """An ordered conditional unit of project work."""
138
+
139
+ id: str
140
+ type: str
141
+ label: str | None = None
142
+ when: Literal[True] | RuleAst
143
+ config: dict[str, Any]
144
+ reads: list[str]
145
+ writes: list[str]
146
+ emits: list[str]
147
+ effect_class: EffectClass = Field(alias="effectClass")
148
+ on_error: OnError = Field(alias="onError")
149
+ capabilities: list[str]
150
+
151
+
152
+ class DocumentPreviewSettings(Model):
153
+ """Preview configuration for a document definition."""
154
+
155
+ mode: Literal["none", "text", "html", "drawing-json"]
156
+ enabled: bool
157
+
158
+
159
+ class DocumentDefinition(Model):
160
+ """A deferred document output definition."""
161
+
162
+ id: str
163
+ type: Literal["text", "markdown", "xlsx", "docx", "image", "dxf"]
164
+ template_ref: str = Field(alias="templateRef")
165
+ template_manifest_ref: str | None = Field(
166
+ default=None, alias="templateManifestRef"
167
+ )
168
+ driver: str
169
+ output_target: str | None = Field(default=None, alias="outputTarget")
170
+ ir_family: Literal["flow", "sheet", "drawing", "raster", "plain"] | None = (
171
+ Field(default=None, alias="irFamily")
172
+ )
173
+ settings: dict[str, Any] = Field(default_factory=dict)
174
+ preview: DocumentPreviewSettings | None = None
175
+
176
+
177
+ class TemplateSlot(Model):
178
+ """A declared template slot."""
179
+
180
+ kind: Literal[
181
+ "text",
182
+ "richtext",
183
+ "repeating_table",
184
+ "image",
185
+ "cell",
186
+ "named_range",
187
+ "block",
188
+ "layer",
189
+ ]
190
+ required: bool = False
191
+
192
+
193
+ class TemplateManifest(Model):
194
+ """Sidecar metadata for a document template."""
195
+
196
+ template_id: str = Field(alias="templateId")
197
+ template_version: str = Field(alias="templateVersion")
198
+ document_type: Literal[
199
+ "text", "markdown", "xlsx", "docx", "image", "dxf"
200
+ ] = Field(alias="documentType")
201
+ driver: str
202
+ required_inputs: list[str] = Field(alias="requiredInputs")
203
+ capabilities: list[str]
204
+ declared_slots: dict[str, TemplateSlot] = Field(alias="declaredSlots")
205
+ content_hash: str | None = Field(default=None, alias="contentHash")
206
+
207
+
208
+ class DocumentDriverManifest(Model):
209
+ """Capability advertisement for an installed document driver."""
210
+
211
+ driver_id: str = Field(alias="driverId")
212
+ version: str
213
+ ir_family: Literal["flow", "sheet", "drawing", "raster", "plain"] = Field(
214
+ alias="irFamily"
215
+ )
216
+ capabilities: list[str]
217
+ supports_preview: bool = Field(alias="supportsPreview")
218
+ supports_browser_execution: bool = Field(alias="supportsBrowserExecution")
219
+ supports_diff: bool = Field(alias="supportsDiff")
220
+ requires_backend: bool = Field(alias="requiresBackend")
221
+ template_media_types: list[str] = Field(alias="templateMediaTypes")
222
+
223
+
224
+ class DocumentPreview(Model):
225
+ """Renderer-owned preview payload."""
226
+
227
+ document_id: str = Field(alias="documentId")
228
+ mode: Literal["text", "html", "drawing-json"]
229
+ body: Any
230
+ warnings: list[str]
231
+ generated_at: str = Field(alias="generatedAt")
232
+ renderer: dict[str, str]
233
+
234
+
235
+ class ArtifactRecord(Model):
236
+ """One generated document artifact referenced from run results."""
237
+
238
+ document_id: str = Field(alias="documentId")
239
+ path: str
240
+ media_type: str = Field(alias="mediaType")
241
+ digest_sha256: str = Field(alias="digestSha256")
242
+ byte_length: int = Field(alias="byteLength")
243
+ template_id: str = Field(alias="templateId")
244
+ template_version: str = Field(alias="templateVersion")
245
+ driver_id: str = Field(alias="driverId")
246
+ driver_version: str = Field(alias="driverVersion")
247
+
248
+
249
+ class DocumentOperation(Model):
250
+ """A typed document operation emitted by a bit."""
251
+
252
+ model_config = ConfigDict(extra="allow")
253
+
254
+ document_id: str = Field(alias="documentId")
255
+ op: str
256
+
257
+
258
+ class SetOp(Model):
259
+ """Set one workspace key."""
260
+
261
+ op: Literal["set"]
262
+ key: str
263
+ value: WorkspaceValue
264
+
265
+
266
+ class UnsetOp(Model):
267
+ """Remove one workspace key."""
268
+
269
+ op: Literal["unset"]
270
+ key: str
271
+
272
+
273
+ class RejectOp(Model):
274
+ """Record a rejected workspace mutation."""
275
+
276
+ op: Literal["reject"]
277
+ key: str
278
+ reason: str
279
+
280
+
281
+ PatchOp = Annotated[
282
+ Union[SetOp, UnsetOp, RejectOp],
283
+ Field(discriminator="op"),
284
+ ]
285
+
286
+
287
+ class WorkspacePatch(Model):
288
+ """An ordered, auditable list of workspace mutations."""
289
+
290
+ ops: list[PatchOp]
291
+
292
+
293
+ class BitTypeManifest(Model):
294
+ """Describes an installed bit implementation."""
295
+
296
+ type_id: str = Field(alias="typeId")
297
+ version: str
298
+ config_schema: dict[str, Any] = Field(alias="configSchema")
299
+ effect_class: EffectClass = Field(alias="effectClass")
300
+ capabilities: list[str]
301
+ reads_from_config: bool = Field(default=False, alias="readsFromConfig")
302
+ browser_safe: bool = Field(alias="browserSafe")
303
+
304
+
305
+ class Project(Model):
306
+ """A portable, versioned Roborean project."""
307
+
308
+ schema_version: Literal["1.0.0", "1.1.0"] = Field(alias="schemaVersion")
309
+ id: str
310
+ name: str
311
+ description: str | None = None
312
+ plugin_requirements: list[dict[str, str]] = Field(
313
+ alias="pluginRequirements"
314
+ )
315
+ workspace: dict[str, list[Variable]]
316
+ bits: list[Bit]
317
+ documents: list[DocumentDefinition]
318
+ templates: list[dict[str, str]]
319
+ metadata: dict[str, Any]
320
+
321
+ @property
322
+ def variables(self) -> list[Variable]:
323
+ """Return declared workspace variables."""
324
+ return self.workspace["variables"]
325
+
326
+
327
+ class BitResult(Model):
328
+ """The recorded result of one bit execution."""
329
+
330
+ bit_id: str = Field(alias="bitId")
331
+ type: str
332
+ active: bool
333
+ activation_reason: bool | Literal["always"] = Field(
334
+ alias="activationReason"
335
+ )
336
+ status: Literal["success", "skipped", "failed", "inactive"]
337
+ duration_ms: float = Field(alias="durationMs")
338
+ workspace_patch: WorkspacePatch = Field(alias="workspacePatch")
339
+ document_ops: list[Any] = Field(alias="documentOps")
340
+ diagnostics: list[dict[str, Any]]
341
+ plugin_version: str = Field(alias="pluginVersion")
342
+
343
+
344
+ class CompiledProject(Model):
345
+ """A resolved project ready for deterministic execution."""
346
+
347
+ schema_version: Literal["1.0.0", "1.1.0"] = Field(alias="schemaVersion")
348
+ project_id: str = Field(alias="projectId")
349
+ project_name: str = Field(alias="projectName")
350
+ compiled_at: str = Field(alias="compiledAt")
351
+ engine_version: str = Field(alias="engineVersion")
352
+ rule_profile_version: str = Field(alias="ruleProfileVersion")
353
+ digest: str
354
+ variables: list[Variable]
355
+ bits: list[dict[str, Any]]
356
+ activation_expressions: dict[str, RuleAst] = Field(
357
+ alias="activationExpressions"
358
+ )
359
+ dependency_map: dict[str, dict[str, list[str]]] = Field(
360
+ alias="dependencyMap"
361
+ )
362
+ documents: list[DocumentDefinition]
363
+ templates: list[dict[str, str]]
364
+ plugin_versions: dict[str, str] = Field(alias="pluginVersions")
365
+ diagnostics: list[dict[str, Any]]
366
+
367
+
368
+ class RunResults(Model):
369
+ """The portable output record for a Phase 1 execution."""
370
+
371
+ run_id: str = Field(alias="runId")
372
+ project_id: str = Field(alias="projectId")
373
+ project_digest: str = Field(alias="projectDigest")
374
+ started_at: str = Field(alias="startedAt")
375
+ finished_at: str = Field(alias="finishedAt")
376
+ status: Literal["success", "failed", "aborted"]
377
+ input_workspace_hash: str = Field(alias="inputWorkspaceHash")
378
+ final_workspace_hash: str = Field(alias="finalWorkspaceHash")
379
+ bit_results: list[BitResult] = Field(alias="bitResults")
380
+ artifacts: list[Any]
381
+ engine_version: str = Field(alias="engineVersion")
382
+ rule_profile_version: str = Field(alias="ruleProfileVersion")
383
+
384
+
385
+ class RunTrigger(str, Enum):
386
+ """How a durable run was requested."""
387
+
388
+ CLI = "cli"
389
+ API = "api"
390
+ RETRY = "retry"
391
+ TEST = "test"
392
+
393
+
394
+ class RunRequest(Model):
395
+ """Idempotent request to execute a project."""
396
+
397
+ project_id: str = Field(alias="projectId")
398
+ project_revision: str | None = Field(default=None, alias="projectRevision")
399
+ idempotency_key: str = Field(alias="idempotencyKey")
400
+ trigger: RunTrigger
401
+ workspace_overrides: dict[str, WorkspaceValue] = Field(
402
+ default_factory=dict, alias="workspaceOverrides"
403
+ )
404
+ strict_workspace_access: bool = Field(
405
+ default=True, alias="strictWorkspaceAccess"
406
+ )
407
+ retry_of_run_id: str | None = Field(default=None, alias="retryOfRunId")
408
+ requested_at: str | None = Field(default=None, alias="requestedAt")
409
+
410
+ @field_validator("idempotency_key")
411
+ @classmethod
412
+ def _validate_idempotency_key(cls, value: str) -> str:
413
+ """Reject empty or oversized keys early."""
414
+ if not value or len(value) > 128:
415
+ raise ValueError("idempotencyKey must be 1..128 characters")
416
+ return value
417
+
418
+
419
+ class WorkspaceChange(Model):
420
+ """One workspace key delta for a run diff."""
421
+
422
+ key: str
423
+ before: WorkspaceValue | None
424
+ after: WorkspaceValue | None
425
+
426
+
427
+ class SecretRefAccess(Model):
428
+ """Metadata that a secret reference was touched without revealing it."""
429
+
430
+ bit_id: str = Field(alias="bitId")
431
+ provider: str
432
+ name: str
433
+ version: str | None = None
434
+
435
+
436
+ class RunDiff(Model):
437
+ """Redacted provenance summary for a completed run."""
438
+
439
+ workspace_changes: list[WorkspaceChange] = Field(alias="workspaceChanges")
440
+ bits_activated: list[str] = Field(alias="bitsActivated")
441
+ bits_skipped_inactive: list[str] = Field(alias="bitsSkippedInactive")
442
+ bits_failed: list[str] = Field(alias="bitsFailed")
443
+ secret_refs_accessed: list[SecretRefAccess] = Field(
444
+ alias="secretRefsAccessed"
445
+ )
446
+ document_ops_count: dict[str, int] = Field(alias="documentOpsCount")
447
+
448
+
449
+ class RunError(Model):
450
+ """Structured failure information for a durable run."""
451
+
452
+ code: str
453
+ message: str
454
+ bit_id: str | None = Field(default=None, alias="bitId")
455
+
456
+
457
+ class RunStatus(str, Enum):
458
+ """Lifecycle status for a durable run record."""
459
+
460
+ QUEUED = "queued"
461
+ RUNNING = "running"
462
+ SUCCEEDED = "succeeded"
463
+ FAILED = "failed"
464
+ CANCELLED = "cancelled"
465
+
466
+
467
+ class RunRecord(Model):
468
+ """Durable envelope around compile/run artifacts."""
469
+
470
+ run_id: str = Field(alias="runId")
471
+ idempotency_key: str = Field(alias="idempotencyKey")
472
+ project_id: str = Field(alias="projectId")
473
+ project_revision: str = Field(alias="projectRevision")
474
+ compiled_digest: str = Field(alias="compiledDigest")
475
+ status: RunStatus
476
+ request: RunRequest
477
+ results: RunResults | None = None
478
+ diff: RunDiff | None = None
479
+ attempt: int = 1
480
+ retry_policy_snapshot: dict[str, Any] = Field(
481
+ default_factory=dict, alias="retryPolicySnapshot"
482
+ )
483
+ engine_version: str = Field(alias="engineVersion")
484
+ plugin_versions: dict[str, str] = Field(alias="pluginVersions")
485
+ error: RunError | None = None
486
+ created_at: str = Field(alias="createdAt")
487
+ started_at: str | None = Field(default=None, alias="startedAt")
488
+ finished_at: str | None = Field(default=None, alias="finishedAt")
489
+ request_digest: str = Field(default="", alias="requestDigest")
@@ -0,0 +1,55 @@
1
+ """Load and validate canonical Roborean JSON Schemas."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from jsonschema import Draft202012Validator, FormatChecker, RefResolver
8
+
9
+
10
+ def find_repo_root() -> Path:
11
+ """Find the repository root containing the project schema."""
12
+ # Walk from this installed source location toward a repository checkout.
13
+ for parent in Path(__file__).resolve().parents:
14
+ if (parent / "schemas" / "project.schema.json").is_file():
15
+ return parent
16
+
17
+ raise FileNotFoundError("Could not find schemas/project.schema.json")
18
+
19
+
20
+ def schema_dir() -> Path:
21
+ """Return the canonical schema directory."""
22
+ return find_repo_root() / "schemas"
23
+
24
+
25
+ def load_schema(name: str) -> dict[str, Any]:
26
+ """Load one canonical schema by logical name."""
27
+ # Preserve meta paths while adding the standard schema suffix elsewhere.
28
+ relative = Path(name)
29
+ filename = relative.name
30
+ if not filename.endswith(".json"):
31
+ filename = f"{filename}.schema.json"
32
+ return json.loads((schema_dir() / relative.parent / filename).read_text())
33
+
34
+
35
+ def validate_instance(schema_name: str, data: dict[str, Any]) -> None:
36
+ """Validate data using its schema and every local schema as a ref store."""
37
+ # Build a resolver store so relative schema references work consistently.
38
+ directory = schema_dir()
39
+ store: dict[str, Any] = {}
40
+ for path in directory.rglob("*.json"):
41
+ schema = json.loads(path.read_text())
42
+ schema_id = schema.get("$id")
43
+ if schema_id:
44
+ store[schema_id] = schema
45
+
46
+ schema = load_schema(schema_name)
47
+ resolver = RefResolver.from_schema(schema, store=store)
48
+ validator = Draft202012Validator(
49
+ schema,
50
+ resolver=resolver,
51
+ format_checker=FormatChecker(),
52
+ )
53
+
54
+ # Raise jsonschema's standard, information-rich validation exception.
55
+ validator.validate(data)
@@ -0,0 +1,3 @@
1
+ """Specification package version metadata."""
2
+
3
+ SPEC_VERSION = "0.1.0"
@@ -0,0 +1,17 @@
1
+ """Shared fixtures for Roborean spec tests."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+
8
+ @pytest.fixture(scope="session")
9
+ def repo_root() -> Path:
10
+ """Return the repository root."""
11
+ return Path(__file__).resolve().parents[4]
12
+
13
+
14
+ @pytest.fixture(scope="session")
15
+ def conformance_dir(repo_root: Path) -> Path:
16
+ """Return the shared conformance corpus."""
17
+ return repo_root / "conformance"
@@ -0,0 +1,42 @@
1
+ """Tests for schema-backed Pydantic models."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from pydantic import ValidationError
7
+ from roborean_spec import project_from_dict, project_to_dict
8
+ from roborean_spec.models import PublicLiteral, Variable
9
+
10
+
11
+ class TestModels:
12
+ """Verify model parsing and JSON round trips."""
13
+
14
+ def test_project_round_trip(self, conformance_dir) -> None:
15
+ """Preserve the canonical project representation."""
16
+ data = json.loads(
17
+ (conformance_dir / "projects" / "01_minimal.json").read_text()
18
+ )
19
+
20
+ assert project_to_dict(project_from_dict(data)) == data
21
+
22
+ def test_unknown_workspace_kind_fails(self) -> None:
23
+ """Reject a workspace value outside the discriminated union."""
24
+ with pytest.raises(ValidationError):
25
+ Variable.model_validate(
26
+ {
27
+ "key": "value",
28
+ "schema": {},
29
+ "defaultValue": {"kind": "unknown"},
30
+ "exposure": "clientVisible",
31
+ }
32
+ )
33
+
34
+ def test_public_literal(self) -> None:
35
+ """Parse a public literal through its explicit model."""
36
+ value = PublicLiteral(
37
+ kind="public_literal",
38
+ dataType="string",
39
+ value="Hello",
40
+ )
41
+
42
+ assert value.value == "Hello"