dirigent-core 0.9.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.
Files changed (53) hide show
  1. dirigent_core/__init__.py +26 -0
  2. dirigent_core/alembic/env.py +71 -0
  3. dirigent_core/alembic/script.py.mako +26 -0
  4. dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
  5. dirigent_core/alerting.py +805 -0
  6. dirigent_core/artifacts.py +73 -0
  7. dirigent_core/auth.py +529 -0
  8. dirigent_core/blockdocs.py +223 -0
  9. dirigent_core/config.py +421 -0
  10. dirigent_core/configdocs.py +134 -0
  11. dirigent_core/database.py +177 -0
  12. dirigent_core/directory.py +339 -0
  13. dirigent_core/documents.py +878 -0
  14. dirigent_core/documentschema.py +92 -0
  15. dirigent_core/engine/__init__.py +120 -0
  16. dirigent_core/engine/claim.py +156 -0
  17. dirigent_core/engine/context.py +420 -0
  18. dirigent_core/engine/definition.py +543 -0
  19. dirigent_core/engine/executor.py +1029 -0
  20. dirigent_core/engine/failure.py +71 -0
  21. dirigent_core/engine/recovery.py +137 -0
  22. dirigent_core/engine/references.py +239 -0
  23. dirigent_core/engine/runs.py +865 -0
  24. dirigent_core/engine/services.py +74 -0
  25. dirigent_core/engine/state.py +434 -0
  26. dirigent_core/ids.py +39 -0
  27. dirigent_core/logging.py +310 -0
  28. dirigent_core/migrations.py +98 -0
  29. dirigent_core/models.py +626 -0
  30. dirigent_core/pipelines.py +494 -0
  31. dirigent_core/plugins.py +255 -0
  32. dirigent_core/protocol.py +276 -0
  33. dirigent_core/py.typed +0 -0
  34. dirigent_core/ratelimit.py +42 -0
  35. dirigent_core/registry.py +32 -0
  36. dirigent_core/retention.py +293 -0
  37. dirigent_core/scheduler.py +491 -0
  38. dirigent_core/schemas.py +147 -0
  39. dirigent_core/secrets.py +164 -0
  40. dirigent_core/storage.py +349 -0
  41. dirigent_core/telemetry.py +402 -0
  42. dirigent_core/trigger_documents.py +193 -0
  43. dirigent_core/triggers/__init__.py +117 -0
  44. dirigent_core/triggers/backfill.py +131 -0
  45. dirigent_core/triggers/materialize.py +218 -0
  46. dirigent_core/triggers/schedules.py +531 -0
  47. dirigent_core/triggers/webhooks.py +586 -0
  48. dirigent_core/types.py +59 -0
  49. dirigent_core/worker.py +350 -0
  50. dirigent_core-0.9.0.dist-info/METADATA +29 -0
  51. dirigent_core-0.9.0.dist-info/RECORD +53 -0
  52. dirigent_core-0.9.0.dist-info/WHEEL +4 -0
  53. dirigent_core-0.9.0.dist-info/licenses/LICENSE +18 -0
@@ -0,0 +1,878 @@
1
+ """Reading, writing, and validating a ``dirigent/v1`` document."""
2
+
3
+ import difflib
4
+ import hashlib
5
+ from collections.abc import Iterable, Mapping
6
+ from typing import Any, Final, cast, get_args, get_origin
7
+
8
+ import yaml
9
+ from jsonschema import Draft202012Validator, FormatChecker
10
+ from jsonschema import ValidationError as SchemaValidationError
11
+ from jsonschema.exceptions import SchemaError
12
+ from jsonschema.validators import extend as extend_validator # pyright: ignore[reportUnknownVariableType]
13
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError
14
+
15
+ from dirigent_client.schemas import Catalog, ValidationIssue
16
+ from dirigent_common import STORAGE_URI_FORMAT, JsonMap, base_format_checker
17
+ from dirigent_core.engine.definition import (
18
+ FORMAT_V1,
19
+ KIND_PIPELINE,
20
+ KIND_TRIGGERS,
21
+ Document,
22
+ PipelineDefinition,
23
+ TriggersDefinition,
24
+ canonical_document,
25
+ )
26
+ from dirigent_core.engine.references import has_reference, references_in
27
+
28
+ #: High enough that the canonical dump never folds a line, which would shift the digest.
29
+ YAML_WIDTH: Final = 10_000
30
+
31
+
32
+ class PlainLoader(yaml.SafeLoader):
33
+ """A safe loader that leaves a date written as a date alone.
34
+
35
+ YAML resolves ``2026-01-01`` to a ``datetime.date``, which is not a JSON value, so the
36
+ timestamp resolver is removed and such a value stays the string it was written as.
37
+ """
38
+
39
+
40
+ PlainLoader.yaml_implicit_resolvers = {
41
+ first: [(tag, regexp) for tag, regexp in resolvers if tag != "tag:yaml.org,2002:timestamp"]
42
+ for first, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
43
+ }
44
+
45
+
46
+ def safe_load(text: str) -> object:
47
+ """Read YAML (or JSON) the one way dirigent reads it, with dates left as strings."""
48
+ return yaml.load(text, Loader=PlainLoader) # noqa: S506 - PlainLoader is SafeLoader minus a resolver
49
+
50
+
51
+ class CanonicalDumper(yaml.SafeDumper):
52
+ """The one YAML dumper an export uses: block style, and sequences indented under their key."""
53
+
54
+ def increase_indent(self, flow: bool = False, indentless: bool = False) -> None:
55
+ """Indent block sequences under their key rather than beside it."""
56
+ super().increase_indent(flow=flow, indentless=False)
57
+
58
+
59
+ class DocumentError(Exception):
60
+ """A document could not be read at all, or did not satisfy the format."""
61
+
62
+ def __init__(self, message: str, problems: Iterable[str] = ()) -> None:
63
+ """Carry the whole list of problems a document has."""
64
+ self.problems = list(problems) or [message]
65
+ detail = "; ".join(self.problems)
66
+ super().__init__(message if detail == message else f"{message}: {detail}")
67
+
68
+
69
+ def parse_text(text: str) -> JsonMap:
70
+ """Read a document's text as YAML, which accepts its JSON form verbatim."""
71
+ try:
72
+ loaded = safe_load(text)
73
+ except yaml.YAMLError as error:
74
+ raise DocumentError(f"the document is not valid YAML or JSON: {error}") from error
75
+ if loaded is None:
76
+ raise DocumentError("the document is empty")
77
+ if not isinstance(loaded, dict):
78
+ raise DocumentError(f"a document is a mapping, not {type(loaded).__name__}")
79
+ return cast("JsonMap", loaded)
80
+
81
+
82
+ #: The model each document kind is read into.
83
+ MODELS: Final[dict[str, type[PipelineDefinition] | type[TriggersDefinition]]] = {
84
+ KIND_PIPELINE: PipelineDefinition,
85
+ KIND_TRIGGERS: TriggersDefinition,
86
+ }
87
+
88
+
89
+ def load_document(raw: JsonMap) -> Document:
90
+ """Validate a parsed document against the format, reporting every problem at once."""
91
+ kind = _check_envelope(raw)
92
+ try:
93
+ return MODELS[kind].model_validate(raw)
94
+ except ValidationError as error:
95
+ raise DocumentError("the document does not satisfy dirigent/v1", _readable(error, kind)) from error
96
+
97
+
98
+ def load_text(text: str) -> Document:
99
+ """Read a document from its YAML or JSON text, in one call."""
100
+ return load_document(parse_text(text))
101
+
102
+
103
+ def carried_refusal(definition: Document) -> str | None:
104
+ """Why an instance will not store this document, or ``None`` when it will.
105
+
106
+ A document may carry its own connections and its own schemas so that it runs alone under
107
+ ``dg run --local``. An applied document is stored, versioned and exported, so a carried
108
+ credential would be in all three, and a carried schema would be a copy of a resource an
109
+ instance holds once and names. Every door that stores a document refuses one the same way.
110
+ """
111
+ if not isinstance(definition, PipelineDefinition):
112
+ return None
113
+ if definition.connections:
114
+ named = ", ".join(sorted(definition.connections))
115
+ return (
116
+ f"this document carries its own connections ({named}), which an instance will not store: "
117
+ "create them with `dg connection create` and let the document name them"
118
+ )
119
+ if definition.schemas:
120
+ named = ", ".join(sorted(definition.schemas))
121
+ return (
122
+ f"this document carries its own schemas ({named}), which an instance will not store: "
123
+ "create them with `dg schema create` and let the document name them in requires.schemas"
124
+ )
125
+ return None
126
+
127
+
128
+ def load_pipeline_text(text: str) -> PipelineDefinition:
129
+ """Read a document that must be a pipeline, naming the kind it turned out to be."""
130
+ definition = load_text(text)
131
+ if not isinstance(definition, PipelineDefinition):
132
+ raise DocumentError(f"this is a `kind: {definition.kind}` document, and a pipeline document was wanted")
133
+ return definition
134
+
135
+
136
+ def _check_envelope(raw: JsonMap) -> str:
137
+ """Refuse a document whose format tag is missing, or whose kind is neither of the two."""
138
+ declared = raw.get("format")
139
+ if declared is None:
140
+ raise DocumentError(f"the document declares no format; add `format: {FORMAT_V1}`")
141
+ if declared != FORMAT_V1:
142
+ raise DocumentError(f"this instance reads {FORMAT_V1} documents, and this one declares {declared!r}")
143
+ kind = raw.get("kind", KIND_PIPELINE)
144
+ if kind not in MODELS:
145
+ known = " and ".join(f"`kind: {name}`" for name in MODELS)
146
+ raise DocumentError(f"{FORMAT_V1} defines {known}, and this document declares {kind!r}")
147
+ return str(kind)
148
+
149
+
150
+ def _readable(error: ValidationError, kind: str = KIND_PIPELINE) -> list[str]:
151
+ """Render pydantic's errors as document paths."""
152
+ problems: list[str] = []
153
+ for detail in error.errors():
154
+ location = ".".join(str(part) for part in detail["loc"]) or "(document)"
155
+ if detail["type"] == "extra_forbidden":
156
+ problems.append(f"{location}: unknown key{_did_you_mean(detail['loc'], kind)}")
157
+ continue
158
+ problems.append(f"{location}: {detail['msg']}")
159
+ return problems
160
+
161
+
162
+ def _did_you_mean(location: tuple[int | str, ...], kind: str) -> str:
163
+ """Name the document field an unknown key is closest to, when one is close enough."""
164
+ model = _model_at(location[:-1], kind)
165
+ if model is None:
166
+ return ""
167
+ known = [field.alias or name for name, field in model.model_fields.items()]
168
+ close = difflib.get_close_matches(str(location[-1]), known, n=1)
169
+ return f" (did you mean {close[0]!r}?)" if close else ""
170
+
171
+
172
+ def _model_at(location: tuple[int | str, ...], kind: str = KIND_PIPELINE) -> type[BaseModel] | None:
173
+ """Walk a validation error's location down the models the format itself defines.
174
+
175
+ Block config is opaque to the format, so the walk stops at ``steps.<name>.config``: an
176
+ unknown key inside one is refused against the block's published schema instead, and gets
177
+ that check's message rather than a suggestion from here.
178
+ """
179
+ current: Any = MODELS.get(kind, PipelineDefinition)
180
+ for part in location:
181
+ if isinstance(current, type) and issubclass(current, BaseModel):
182
+ field = current.model_fields.get(str(part))
183
+ current = None if field is None else field.annotation
184
+ else:
185
+ current = _element_of(current)
186
+ if current is None:
187
+ return None
188
+ return current if isinstance(current, type) and issubclass(current, BaseModel) else None
189
+
190
+
191
+ def _element_of(annotation: Any) -> Any:
192
+ """Read what one entry of a list or map annotation holds, or nothing if it holds neither."""
193
+ args = [arg for arg in get_args(annotation) if arg is not type(None)]
194
+ return args[-1] if get_origin(annotation) in (list, dict) and args else None
195
+
196
+
197
+ def to_yaml(definition: Document) -> str:
198
+ """Render a definition as the canonical YAML an export writes and a digest covers."""
199
+ return yaml.dump(
200
+ canonical_document(definition),
201
+ Dumper=CanonicalDumper,
202
+ sort_keys=False,
203
+ default_flow_style=False,
204
+ allow_unicode=True,
205
+ width=YAML_WIDTH,
206
+ indent=2,
207
+ )
208
+
209
+
210
+ def digest_of(definition: Document) -> str:
211
+ """Hash the canonical form of a definition."""
212
+ return f"sha256:{hashlib.sha256(to_yaml(definition).encode()).hexdigest()}"
213
+
214
+
215
+ def digest_of_document(document: JsonMap) -> str:
216
+ """Hash a stored document by re-canonicalizing it, so storage order never shifts a digest."""
217
+ return digest_of(MODELS[str(document.get("kind", KIND_PIPELINE))].model_validate(document))
218
+
219
+
220
+ class _Deferred:
221
+ """A ``${...}`` reference, opaque until a run resolves it."""
222
+
223
+ def __repr__(self) -> str:
224
+ """Render the sentinel for an error message."""
225
+ return "${...}"
226
+
227
+
228
+ DEFERRED: Final = _Deferred()
229
+
230
+
231
+ def _skip_deferred(keyword: Any) -> Any:
232
+ """Wrap one jsonschema keyword so it passes over a value that is only known at run time."""
233
+
234
+ def validate(validator: Any, value: Any, instance: Any, schema: Any) -> Any:
235
+ if instance is DEFERRED:
236
+ return
237
+ yield from keyword(validator, value, instance, schema)
238
+
239
+ return validate
240
+
241
+
242
+ #: A validator that checks everything a document states literally and defers the rest.
243
+ ConfigValidator: Any = extend_validator( # pyright: ignore[reportUnknownVariableType]
244
+ Draft202012Validator,
245
+ {name: _skip_deferred(keyword) for name, keyword in Draft202012Validator.VALIDATORS.items()},
246
+ )
247
+
248
+
249
+ def defer_references(value: JsonValue) -> object:
250
+ """Replace every value containing a ``${...}`` reference with the deferred sentinel.
251
+
252
+ A config is checked against its block's schema at apply time, when a referenced value has
253
+ no type yet; deferring exactly those values keeps the rest of the check strict.
254
+ """
255
+ match value:
256
+ case str():
257
+ return DEFERRED if has_reference(value) else value
258
+ case list():
259
+ return [defer_references(item) for item in value]
260
+ case dict():
261
+ return {key: defer_references(item) for key, item in value.items()}
262
+ case _:
263
+ return value
264
+
265
+
266
+ class TriggerTarget(BaseModel):
267
+ """The pipeline a triggers document names, as the instance currently holds it."""
268
+
269
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
270
+
271
+ code: str
272
+ active: bool = True
273
+ definition: PipelineDefinition | None = None
274
+ """Its current version, or None when it has none and therefore declares no parameters."""
275
+
276
+
277
+ def validate_against_catalog(
278
+ definition: Document,
279
+ catalog: Catalog,
280
+ *,
281
+ connections: Iterable[str] = (),
282
+ pipelines: Iterable[str] = (),
283
+ check_blocks: bool = True,
284
+ unsafe_allowed: Iterable[str] | None = None,
285
+ storage_schemes: Iterable[str] | None = None,
286
+ schemas: Iterable[str] | None = None,
287
+ blocks: Mapping[str, Any] | None = None,
288
+ format_checker: FormatChecker | None = None,
289
+ target: TriggerTarget | None = None,
290
+ ) -> list[ValidationIssue]:
291
+ """Check a document against what this instance actually has, reporting everything at once.
292
+
293
+ ``check_blocks`` is off on the offline path, where no catalog is available to check against.
294
+ ``unsafe_allowed`` and ``storage_schemes`` are what the instance permits and what its
295
+ backends claim; left unset, neither is checked, because a caller that does not know them
296
+ would otherwise refuse a document the instance would have run. ``blocks`` is the live
297
+ contributed blocks keyed by id, which is what lets each one make its own extra refusals;
298
+ a caller holding no host passes none and the check is the schema alone. ``format_checker``
299
+ is the instance's assembled checker, which the pinned parameters of a schedule are checked
300
+ against; unset, they are checked against the base formats alone, which is all a caller
301
+ holding no host can know. ``target`` is the pipeline a triggers document names, as the
302
+ instance holds it now; on the offline path there is none to read, and only the clocks and
303
+ the codes are checked.
304
+ """
305
+ checker = format_checker if format_checker is not None else base_format_checker()
306
+ if isinstance(definition, TriggersDefinition):
307
+ return _triggers_document_issues(definition, checker, target, check_target=check_blocks)
308
+ # A document that brings its own connections or schemas satisfies its own references to them.
309
+ known = {name for name in connections} | set(definition.connections)
310
+ known_schemas = None if schemas is None else {name for name in schemas} | set(definition.schemas)
311
+ held = {name for name in pipelines}
312
+ issues: list[ValidationIssue] = []
313
+ if check_blocks:
314
+ issues.extend(
315
+ _requirement_issues(
316
+ definition,
317
+ catalog,
318
+ known,
319
+ held,
320
+ None if storage_schemes is None else set(storage_schemes),
321
+ known_schemas,
322
+ )
323
+ )
324
+ malformed_params = _params_schema_issues(definition)
325
+ issues.extend(malformed_params)
326
+ issues.extend(_carried_schema_issues(definition))
327
+ issues.extend(_reference_issues(definition))
328
+ issues.extend(_trigger_issues(definition, checker, check_params=not malformed_params))
329
+ if check_blocks:
330
+ issues.extend(
331
+ _step_block_issues(
332
+ definition,
333
+ catalog,
334
+ known,
335
+ None if unsafe_allowed is None else set(unsafe_allowed),
336
+ None if storage_schemes is None else set(storage_schemes),
337
+ blocks,
338
+ known_schemas,
339
+ )
340
+ )
341
+ return issues
342
+
343
+
344
+ def worker_routing_issues(definition: PipelineDefinition, carried: set[str]) -> list[ValidationIssue]:
345
+ """Report the tags a document requires of a worker that no live worker carries.
346
+
347
+ Never a refusal: workers come and go, and a run of the pipeline simply queues until one
348
+ carrying the tags registers.
349
+ """
350
+ missing = [tag for tag in definition.requires.workers if tag not in carried]
351
+ if not missing:
352
+ return []
353
+ return [
354
+ ValidationIssue(
355
+ location="requires.workers",
356
+ message=(f"no live worker carries {', '.join(missing)}; a run of this pipeline would wait until one does"),
357
+ )
358
+ ]
359
+
360
+
361
+ def _params_schema_issues(definition: PipelineDefinition) -> list[ValidationIssue]:
362
+ """Check that the parameter schema a document declares is itself a JSON Schema.
363
+
364
+ Nothing else ever checks it, and a schema that is not one only fails when the first run
365
+ is created, from inside jsonschema, long after the document was stored.
366
+ """
367
+ if not definition.params:
368
+ return []
369
+ try:
370
+ Draft202012Validator.check_schema(definition.params)
371
+ except SchemaError as error:
372
+ where = "/".join(str(part) for part in error.absolute_path)
373
+ return [
374
+ ValidationIssue(
375
+ location="params",
376
+ message=(
377
+ "the parameter schema is not itself valid JSON Schema"
378
+ + (f" at {where}" if where else "")
379
+ + f": {error.message}"
380
+ ),
381
+ )
382
+ ]
383
+ return []
384
+
385
+
386
+ def _carried_schema_issues(definition: PipelineDefinition) -> list[ValidationIssue]:
387
+ """Check that each schema a document carries is itself a valid JSON Schema.
388
+
389
+ A carried schema is never stored on its own, so this apply-time check is the only thing
390
+ that catches a body that is not a schema before a gate tries to validate against it.
391
+ """
392
+ issues: list[ValidationIssue] = []
393
+ for code, body in definition.schemas.items():
394
+ try:
395
+ Draft202012Validator.check_schema(body)
396
+ except SchemaError as error:
397
+ where = "/".join(str(part) for part in error.absolute_path)
398
+ issues.append(
399
+ ValidationIssue(
400
+ location=f"schemas.{code}",
401
+ message=(
402
+ "the carried schema is not itself valid JSON Schema"
403
+ + (f" at {where}" if where else "")
404
+ + f": {error.message}"
405
+ ),
406
+ )
407
+ )
408
+ return issues
409
+
410
+
411
+ def _trigger_issues(
412
+ definition: Document,
413
+ format_checker: FormatChecker,
414
+ *,
415
+ check_params: bool = True,
416
+ against: PipelineDefinition | None = None,
417
+ ) -> list[ValidationIssue]:
418
+ """Check the clocks a document declares, the parameters its schedules pin, and its mappings.
419
+
420
+ ``check_params`` is off when the parameter schema is not itself a schema, which is already
421
+ reported and would otherwise be reported again once per trigger. ``against`` is the
422
+ definition whose parameter schema the pins and the mappings are checked against, which for
423
+ a triggers document is another document's current version.
424
+ """
425
+ from dirigent_core.triggers.schedules import (
426
+ ScheduleError,
427
+ ScheduleRequest,
428
+ check_schedule,
429
+ check_schedule_params,
430
+ )
431
+ from dirigent_core.triggers.webhooks import WebhookError, check_webhook_mapping
432
+
433
+ pins_against = against if against is not None else definition
434
+ issues: list[ValidationIssue] = []
435
+ for index, spec in enumerate(definition.triggers.schedules):
436
+ try:
437
+ check_schedule(ScheduleRequest.from_spec(spec))
438
+ except (ScheduleError, ValidationError) as error:
439
+ issues.append(
440
+ ValidationIssue(location=f"triggers.schedules[{index}].{spec.code}", message=str(error).strip())
441
+ )
442
+ if not check_params or not isinstance(pins_against, PipelineDefinition):
443
+ continue
444
+ try:
445
+ check_schedule_params(pins_against, spec.params, format_checker)
446
+ except ScheduleError as error:
447
+ issues.append(ValidationIssue(location=f"triggers.schedules[{index}].params", message=str(error).strip()))
448
+ if not check_params or not isinstance(pins_against, PipelineDefinition):
449
+ return issues
450
+ for index, hook in enumerate(definition.triggers.webhooks):
451
+ try:
452
+ check_webhook_mapping(pins_against, hook.params_from_payload)
453
+ except WebhookError as error:
454
+ issues.append(
455
+ ValidationIssue(location=f"triggers.webhooks[{index}].params_from_payload", message=str(error).strip())
456
+ )
457
+ return issues
458
+
459
+
460
+ def _triggers_document_issues(
461
+ definition: TriggersDefinition,
462
+ format_checker: FormatChecker,
463
+ target: TriggerTarget | None,
464
+ *,
465
+ check_target: bool,
466
+ ) -> list[ValidationIssue]:
467
+ """Check a triggers document: the pipeline it names, its clocks, and the pins they carry."""
468
+ if not check_target:
469
+ return _trigger_issues(definition, format_checker, check_params=False)
470
+ if target is None:
471
+ return [
472
+ ValidationIssue(
473
+ location="pipeline",
474
+ message=(
475
+ f"no pipeline coded {definition.pipeline!r} exists on this instance; "
476
+ "apply it before the document that schedules it"
477
+ ),
478
+ )
479
+ ]
480
+ if not target.active:
481
+ return [
482
+ ValidationIssue(
483
+ location="pipeline",
484
+ message=(
485
+ f"the pipeline coded {definition.pipeline!r} is inactive; "
486
+ "activate it before the document that schedules it"
487
+ ),
488
+ )
489
+ ]
490
+ return _trigger_issues(definition, format_checker, against=target.definition)
491
+
492
+
493
+ def _requirement_issues(
494
+ definition: PipelineDefinition,
495
+ catalog: Catalog,
496
+ connections: set[str],
497
+ pipelines: set[str],
498
+ schemes: set[str] | None,
499
+ schemas: set[str] | None,
500
+ ) -> list[ValidationIssue]:
501
+ """Check the ``requires`` preflight first, with one complete list of what is missing."""
502
+ installed = {entry.id for entry in catalog.blocks}
503
+ issues = [
504
+ ValidationIssue(
505
+ location=f"requires.blocks.{index}",
506
+ message=f"block {block!r} is not installed on this instance",
507
+ )
508
+ for index, block in enumerate(definition.requires.blocks)
509
+ if block not in installed
510
+ ]
511
+ issues.extend(
512
+ ValidationIssue(
513
+ location=f"requires.connections.{index}",
514
+ message=f"no connection coded {name!r} exists on this instance",
515
+ )
516
+ for index, name in enumerate(definition.requires.connections)
517
+ if name not in connections
518
+ )
519
+ issues.extend(
520
+ ValidationIssue(
521
+ location=f"requires.pipelines.{index}",
522
+ message=(f"no pipeline coded {name!r} exists on this instance; apply it before the document that runs it"),
523
+ )
524
+ for index, name in enumerate(definition.requires.pipelines)
525
+ if name not in pipelines and name != definition.code
526
+ )
527
+ if schemes is not None:
528
+ registered = ", ".join(sorted(schemes)) or "none are registered"
529
+ issues.extend(
530
+ ValidationIssue(
531
+ location=f"requires.storage.{index}",
532
+ message=f"no storage backend claims {scheme!r} ({registered})",
533
+ )
534
+ for index, scheme in enumerate(definition.requires.storage)
535
+ if scheme not in schemes
536
+ )
537
+ if schemas is not None:
538
+ held = ", ".join(sorted(schemas)) or "none are held"
539
+ issues.extend(
540
+ ValidationIssue(
541
+ location=f"requires.schemas.{index}",
542
+ message=f"no schema coded {name!r} exists on this instance ({held})",
543
+ )
544
+ for index, name in enumerate(definition.requires.schemas)
545
+ if name not in schemas
546
+ )
547
+ return issues
548
+
549
+
550
+ def _step_block_issues(
551
+ definition: PipelineDefinition,
552
+ catalog: Catalog,
553
+ connections: set[str],
554
+ allowed: set[str] | None = None,
555
+ schemes: set[str] | None = None,
556
+ blocks: Mapping[str, Any] | None = None,
557
+ schemas: set[str] | None = None,
558
+ ) -> list[ValidationIssue]:
559
+ """Check every step against the catalog: the block exists, and its config fits the schema."""
560
+ issues: list[ValidationIssue] = []
561
+ for name in sorted(definition.steps):
562
+ step = definition.steps[name]
563
+ entry = catalog.block(step.block)
564
+ if entry is None:
565
+ issues.append(
566
+ ValidationIssue(
567
+ location=f"steps.{name}.block",
568
+ message=f"no block {step.block!r} is installed; install the plugin package that contributes it",
569
+ )
570
+ )
571
+ continue
572
+ malformed = _config_issues(name, step.config, entry.config_schema)
573
+ issues.extend(malformed)
574
+ issues.extend(_connection_issues(name, step.config, connections))
575
+ issues.extend(_schema_issues(name, step.config, schemas))
576
+ issues.extend(_allowlist_issues(name, step.block, entry, allowed))
577
+ issues.extend(_scheme_issues(name, step.config, entry.config_schema, schemes))
578
+ if not malformed:
579
+ issues.extend(_block_refusals(name, step.block, step.config, blocks))
580
+ return issues
581
+
582
+
583
+ def _block_refusals(
584
+ step: str, block_id: str, config: JsonMap, blocks: Mapping[str, Any] | None
585
+ ) -> list[ValidationIssue]:
586
+ """Ask the live block what else it refuses, once its config is known to fit the schema.
587
+
588
+ A config still carrying a ``${...}`` is not known yet, so it is left to the run: a block
589
+ asked to compile a program that is a reference would refuse the reference rather than
590
+ the program.
591
+ """
592
+ block = None if blocks is None else blocks.get(block_id)
593
+ if block is None or references_in(cast("JsonValue", config)):
594
+ return []
595
+ try:
596
+ validated = block.config_model.model_validate(config)
597
+ except ValidationError:
598
+ return []
599
+ return [
600
+ ValidationIssue(location=f"steps.{step}.config", message=message)
601
+ for message in cast("list[str]", block.check_config(validated))
602
+ ]
603
+
604
+
605
+ def _allowlist_issues(step: str, block: str, entry: Any, allowed: set[str] | None) -> list[ValidationIssue]:
606
+ """Refuse a step whose block runs code on the worker where the instance does not allow it.
607
+
608
+ The execution path refuses it too. Saying so at apply is what stops a document being
609
+ stored, scheduled, and only then found to be unrunnable.
610
+ """
611
+ if allowed is None or not getattr(entry, "local_execution", False) or block in allowed:
612
+ return []
613
+ permitted = ", ".join(sorted(allowed)) or "none"
614
+ return [
615
+ ValidationIssue(
616
+ location=f"steps.{step}.block",
617
+ message=(
618
+ f"block {block!r} executes code on the worker and DIRIGENT_ENABLED_UNSAFE_BLOCKS "
619
+ f"does not name it (currently allowed: {permitted})"
620
+ ),
621
+ )
622
+ ]
623
+
624
+
625
+ def _storage_fields(schema: JsonMap) -> set[str]:
626
+ """Name the config fields a block publishes as storage URIs, and nothing else.
627
+
628
+ A block says which of its strings address storage. Guessing from the value instead would
629
+ refuse an ``http.request`` whose ``url`` is perfectly good: nothing tells the two apart
630
+ by looking.
631
+ """
632
+ properties = schema.get("properties")
633
+ if not isinstance(properties, dict):
634
+ return set()
635
+ named: set[str] = set()
636
+ for field, declared in cast("dict[str, Any]", properties).items():
637
+ if not isinstance(declared, dict):
638
+ continue
639
+ shape = cast("dict[str, Any]", declared)
640
+ options = cast("list[dict[str, Any]]", shape.get("anyOf") or [])
641
+ shapes: list[dict[str, Any]] = [shape, *options]
642
+ if any(one.get("format") == STORAGE_URI_FORMAT for one in shapes):
643
+ named.add(str(field))
644
+ return named
645
+
646
+
647
+ def _scheme_issues(step: str, config: JsonMap, schema: JsonMap, schemes: set[str] | None) -> list[ValidationIssue]:
648
+ """Refuse a step addressing a storage scheme no backend claims."""
649
+ if schemes is None:
650
+ return []
651
+ issues: list[ValidationIssue] = []
652
+ for key in sorted(_storage_fields(schema) & set(config)):
653
+ value = config[key]
654
+ if not isinstance(value, str) or has_reference(value) or "://" not in value:
655
+ continue
656
+ scheme = value.split("://", 1)[0]
657
+ if scheme in schemes:
658
+ continue
659
+ registered = ", ".join(sorted(schemes)) or "none are registered"
660
+ issues.append(
661
+ ValidationIssue(
662
+ location=f"steps.{step}.config.{key}",
663
+ message=f"no storage backend claims {scheme!r} ({registered})",
664
+ )
665
+ )
666
+ return issues
667
+
668
+
669
+ def _config_issues(step: str, config: JsonMap, schema: JsonMap) -> list[ValidationIssue]:
670
+ """Validate one step's config against its block's published schema."""
671
+ if not schema:
672
+ return []
673
+ deferred = defer_references(config)
674
+ validator: Any = ConfigValidator(schema)
675
+ raised: Any = validator.iter_errors(deferred) # pyright: ignore[reportUnknownMemberType]
676
+ found = cast("list[SchemaValidationError]", list(raised))
677
+ issues: list[ValidationIssue] = []
678
+ for error in sorted(found, key=lambda item: [str(part) for part in item.absolute_path]):
679
+ path = ".".join(str(part) for part in error.absolute_path)
680
+ issues.append(
681
+ ValidationIssue(
682
+ location=f"steps.{step}.config" + (f".{path}" if path else ""),
683
+ message=error.message,
684
+ )
685
+ )
686
+ return issues
687
+
688
+
689
+ def _connection_issues(step: str, config: JsonMap, connections: set[str]) -> list[ValidationIssue]:
690
+ """Refuse a step naming a connection this instance does not hold."""
691
+ named = config.get("connection")
692
+ if not isinstance(named, str) or has_reference(named) or named in connections:
693
+ return []
694
+ available = ", ".join(sorted(connections)) or "this instance has no connections"
695
+ return [
696
+ ValidationIssue(
697
+ location=f"steps.{step}.config.connection",
698
+ message=f"no connection coded {named!r} exists ({available})",
699
+ )
700
+ ]
701
+
702
+
703
+ def _schema_issues(step: str, config: JsonMap, schemas: set[str] | None) -> list[ValidationIssue]:
704
+ """Refuse a step naming a schema no instance holds and the document does not carry."""
705
+ if schemas is None:
706
+ return []
707
+ named = config.get("schema")
708
+ if not isinstance(named, str) or has_reference(named) or named in schemas:
709
+ return []
710
+ available = ", ".join(sorted(schemas)) or "this instance holds no schemas"
711
+ return [
712
+ ValidationIssue(
713
+ location=f"steps.{step}.config.schema",
714
+ message=f"no schema coded {named!r} exists ({available})",
715
+ )
716
+ ]
717
+
718
+
719
+ def _reference_issues(definition: PipelineDefinition) -> list[ValidationIssue]:
720
+ """Check every ``${...}`` a document writes against what a run will actually have."""
721
+ declared = _declared_params(definition.params)
722
+ issues: list[ValidationIssue] = []
723
+ for name in sorted(definition.steps):
724
+ step = definition.steps[name]
725
+ upstream = _ancestors(definition, name)
726
+ for reference in sorted(set(references_in(cast("JsonValue", step.config)))):
727
+ problem = _reference_problem(reference.strip(), definition, name, upstream, declared)
728
+ if problem is not None:
729
+ issues.append(ValidationIssue(location=f"steps.{name}.config", message=problem))
730
+ issues.extend(_fan_out_literal_issues(name, step.for_each))
731
+ # for_each carries one extra rule: cardinality is fixed when the run is created, so
732
+ # it cannot read a step's output.
733
+ for reference in sorted(set(references_in(cast("JsonValue", step.for_each)))):
734
+ problem = _for_each_problem(reference.strip(), definition, name, declared)
735
+ if problem is not None:
736
+ issues.append(ValidationIssue(location=f"steps.{name}.for_each", message=problem))
737
+ return issues
738
+
739
+
740
+ def _fan_out_literal_issues(step: str, for_each: str | list[JsonValue] | None) -> list[ValidationIssue]:
741
+ """Refuse a ``for_each`` string that interpolates nothing, since it stays a string at run time."""
742
+ if not isinstance(for_each, str) or has_reference(for_each):
743
+ return []
744
+ return [
745
+ ValidationIssue(
746
+ location=f"steps.{step}.for_each",
747
+ message=(
748
+ f"for_each is a literal string, not a list: {for_each!r} maps over nothing. "
749
+ "Write a list, or an interpolation such as ${params.regions}"
750
+ ),
751
+ )
752
+ ]
753
+
754
+
755
+ def _for_each_problem(
756
+ reference: str,
757
+ definition: PipelineDefinition,
758
+ step: str,
759
+ declared: set[str] | None,
760
+ ) -> str | None:
761
+ """Say what is wrong with a reference inside ``for_each``, or nothing when it will resolve."""
762
+ parts = [part for part in reference.split(".") if part]
763
+ if parts and parts[0] == "steps":
764
+ return (
765
+ f"${{{reference}}} reads a step's output, and fan-out is expanded when the run is created: "
766
+ "for_each may read params and run, but not another step's output"
767
+ )
768
+ if parts and parts[0] == "item":
769
+ return f"${{{reference}}} reads the fan-out item, which does not exist yet inside for_each itself"
770
+ return _reference_problem(reference, definition, step, set(), declared)
771
+
772
+
773
+ def _reference_problem(
774
+ reference: str,
775
+ definition: PipelineDefinition,
776
+ step: str,
777
+ upstream: set[str],
778
+ declared: set[str] | None,
779
+ ) -> str | None:
780
+ """Say what is wrong with one reference, or nothing when it will resolve."""
781
+ parts = [part for part in reference.split(".") if part]
782
+ match parts:
783
+ case []:
784
+ return "${} names nothing"
785
+ case ["params", parameter, *_] if declared is not None and parameter not in declared:
786
+ available = ", ".join(sorted(declared)) or "the pipeline declares no parameters"
787
+ return f"${{{reference}}} reads an undeclared parameter ({available})"
788
+ case ["params", *_]:
789
+ return None
790
+ case ["item", *_]:
791
+ if not definition.steps[step].is_fan_out:
792
+ return f"${{{reference}}} reads the fan-out item, but step {step!r} has no for_each"
793
+ return None
794
+ case ["steps", target, "output", *_]:
795
+ if target not in definition.steps:
796
+ return f"${{{reference}}} names step {target!r}, which this pipeline does not have"
797
+ if target not in upstream:
798
+ return (
799
+ f"${{{reference}}} reads step {target!r}, which is not a prerequisite of {step!r}; "
800
+ f"add it to depends_on"
801
+ )
802
+ return None
803
+ case ["steps", *_]:
804
+ return f"${{{reference}}} is malformed: a step reference reads steps.<name>.output.<field>"
805
+ case ["run", "scratch"] | ["run", "id"] | ["run", "window", "start"] | ["run", "window", "end"]:
806
+ return None
807
+ case ["run", *_]:
808
+ return (
809
+ f"${{{reference}}} is malformed: run exposes only run.scratch, run.id, "
810
+ "run.window.start and run.window.end"
811
+ )
812
+ case [namespace, *_]:
813
+ return f"${{{reference}}} names {namespace!r}, which is not one of params, steps, item, run"
814
+ case _: # pragma: no cover - every shape above is total over a list of strings
815
+ return None
816
+
817
+
818
+ def _declared_params(schema: JsonMap) -> set[str] | None:
819
+ """List the parameter names a schema declares, or None when it accepts anything."""
820
+ if schema.get("additionalProperties") is True:
821
+ return None
822
+ properties = schema.get("properties")
823
+ if not isinstance(properties, dict):
824
+ return None
825
+ return set(cast("Mapping[str, object]", properties))
826
+
827
+
828
+ def _ancestors(definition: PipelineDefinition, name: str) -> set[str]:
829
+ """List every step a step transitively depends on."""
830
+ seen: set[str] = set()
831
+ frontier = list(definition.steps[name].depends_on)
832
+ while frontier:
833
+ current = frontier.pop()
834
+ if current in seen or current not in definition.steps:
835
+ continue
836
+ seen.add(current)
837
+ frontier.extend(definition.steps[current].depends_on)
838
+ return seen
839
+
840
+
841
+ class DocumentSummary(BaseModel):
842
+ """What a document says about itself, for a plan, a listing, or a diff."""
843
+
844
+ model_config = ConfigDict(frozen=True)
845
+
846
+ kind: str = KIND_PIPELINE
847
+ code: str
848
+ name: str | None = None
849
+ description: str | None = None
850
+ pipeline: str | None = None
851
+ """The pipeline a triggers document fires; a pipeline document names none but itself."""
852
+
853
+ steps: list[str] = Field(default_factory=list[str])
854
+ blocks: list[str] = Field(default_factory=list[str])
855
+ schedules: list[str] = Field(default_factory=list[str])
856
+ webhooks: list[str] = Field(default_factory=list[str])
857
+ digest: str
858
+
859
+
860
+ def summarize(definition: Document) -> DocumentSummary:
861
+ """Reduce a definition to the handful of facts a plan or a listing shows."""
862
+ pipeline = definition.pipeline if isinstance(definition, TriggersDefinition) else None
863
+ steps = sorted(definition.steps) if isinstance(definition, PipelineDefinition) else []
864
+ blocks = (
865
+ sorted({step.block for step in definition.steps.values()}) if isinstance(definition, PipelineDefinition) else []
866
+ )
867
+ return DocumentSummary(
868
+ kind=definition.kind,
869
+ code=definition.code,
870
+ name=definition.name,
871
+ description=definition.description,
872
+ pipeline=pipeline,
873
+ steps=steps,
874
+ blocks=blocks,
875
+ schedules=[spec.code for spec in definition.triggers.schedules],
876
+ webhooks=[spec.code for spec in definition.triggers.webhooks],
877
+ digest=digest_of(definition),
878
+ )