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.
- dirigent_core/__init__.py +26 -0
- dirigent_core/alembic/env.py +71 -0
- dirigent_core/alembic/script.py.mako +26 -0
- dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
- dirigent_core/alerting.py +805 -0
- dirigent_core/artifacts.py +73 -0
- dirigent_core/auth.py +529 -0
- dirigent_core/blockdocs.py +223 -0
- dirigent_core/config.py +421 -0
- dirigent_core/configdocs.py +134 -0
- dirigent_core/database.py +177 -0
- dirigent_core/directory.py +339 -0
- dirigent_core/documents.py +878 -0
- dirigent_core/documentschema.py +92 -0
- dirigent_core/engine/__init__.py +120 -0
- dirigent_core/engine/claim.py +156 -0
- dirigent_core/engine/context.py +420 -0
- dirigent_core/engine/definition.py +543 -0
- dirigent_core/engine/executor.py +1029 -0
- dirigent_core/engine/failure.py +71 -0
- dirigent_core/engine/recovery.py +137 -0
- dirigent_core/engine/references.py +239 -0
- dirigent_core/engine/runs.py +865 -0
- dirigent_core/engine/services.py +74 -0
- dirigent_core/engine/state.py +434 -0
- dirigent_core/ids.py +39 -0
- dirigent_core/logging.py +310 -0
- dirigent_core/migrations.py +98 -0
- dirigent_core/models.py +626 -0
- dirigent_core/pipelines.py +494 -0
- dirigent_core/plugins.py +255 -0
- dirigent_core/protocol.py +276 -0
- dirigent_core/py.typed +0 -0
- dirigent_core/ratelimit.py +42 -0
- dirigent_core/registry.py +32 -0
- dirigent_core/retention.py +293 -0
- dirigent_core/scheduler.py +491 -0
- dirigent_core/schemas.py +147 -0
- dirigent_core/secrets.py +164 -0
- dirigent_core/storage.py +349 -0
- dirigent_core/telemetry.py +402 -0
- dirigent_core/trigger_documents.py +193 -0
- dirigent_core/triggers/__init__.py +117 -0
- dirigent_core/triggers/backfill.py +131 -0
- dirigent_core/triggers/materialize.py +218 -0
- dirigent_core/triggers/schedules.py +531 -0
- dirigent_core/triggers/webhooks.py +586 -0
- dirigent_core/types.py +59 -0
- dirigent_core/worker.py +350 -0
- dirigent_core-0.9.0.dist-info/METADATA +29 -0
- dirigent_core-0.9.0.dist-info/RECORD +53 -0
- dirigent_core-0.9.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|