dirigent-cli 0.18.0__tar.gz → 0.18.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.
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/PKG-INFO +9 -9
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/pyproject.toml +9 -9
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/pyproject.toml.orig +9 -9
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/commands.py +45 -6
- dirigent_cli-0.18.2/src/dirigent_cli/explain.py +237 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/main.py +48 -1
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/messages.py +2 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/output.py +50 -1
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/schemas.py +51 -6
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/summaries.py +76 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/triggers.py +28 -15
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/LICENSE +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/README.md +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/__init__.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/aliases.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/context.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/formatters.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/graph.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/health.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/init_form.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/local.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/params.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/profiles.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/project.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/py.typed +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/reaper.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/scaffold.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/seeding.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/sources.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/starters.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/stream.py +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
- {dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/timing.py +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-cli
|
|
3
|
-
Version: 0.18.
|
|
3
|
+
Version: 0.18.2
|
|
4
4
|
Summary: The dirigent command line interface (dirigent / dg).
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
8
8
|
Classifier: Programming Language :: Python :: 3.13
|
|
9
|
-
Requires-Dist: dirigent-block-execute==0.18.
|
|
10
|
-
Requires-Dist: dirigent-blocks==0.18.
|
|
11
|
-
Requires-Dist: dirigent-client==0.18.
|
|
12
|
-
Requires-Dist: dirigent-common==0.18.
|
|
13
|
-
Requires-Dist: dirigent-core==0.18.
|
|
14
|
-
Requires-Dist: dirigent-examples==0.18.
|
|
15
|
-
Requires-Dist: dirigent-plugin==0.18.
|
|
16
|
-
Requires-Dist: dirigent-server==0.18.
|
|
9
|
+
Requires-Dist: dirigent-block-execute==0.18.2
|
|
10
|
+
Requires-Dist: dirigent-blocks==0.18.2
|
|
11
|
+
Requires-Dist: dirigent-client==0.18.2
|
|
12
|
+
Requires-Dist: dirigent-common==0.18.2
|
|
13
|
+
Requires-Dist: dirigent-core==0.18.2
|
|
14
|
+
Requires-Dist: dirigent-examples==0.18.2
|
|
15
|
+
Requires-Dist: dirigent-plugin==0.18.2
|
|
16
|
+
Requires-Dist: dirigent-server==0.18.2
|
|
17
17
|
Requires-Dist: httpx2>=2.12.0
|
|
18
18
|
Requires-Dist: python-dotenv>=1.1.0
|
|
19
19
|
Requires-Dist: pyyaml>=6.0.3
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-cli"
|
|
3
|
-
version = "0.18.
|
|
3
|
+
version = "0.18.2"
|
|
4
4
|
description = "The dirigent command line interface (dirigent / dg)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -11,14 +11,14 @@ classifiers = [
|
|
|
11
11
|
"Programming Language :: Python :: 3.13",
|
|
12
12
|
]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"dirigent-block-execute==0.18.
|
|
15
|
-
"dirigent-blocks==0.18.
|
|
16
|
-
"dirigent-client==0.18.
|
|
17
|
-
"dirigent-common==0.18.
|
|
18
|
-
"dirigent-core==0.18.
|
|
19
|
-
"dirigent-examples==0.18.
|
|
20
|
-
"dirigent-plugin==0.18.
|
|
21
|
-
"dirigent-server==0.18.
|
|
14
|
+
"dirigent-block-execute==0.18.2",
|
|
15
|
+
"dirigent-blocks==0.18.2",
|
|
16
|
+
"dirigent-client==0.18.2",
|
|
17
|
+
"dirigent-common==0.18.2",
|
|
18
|
+
"dirigent-core==0.18.2",
|
|
19
|
+
"dirigent-examples==0.18.2",
|
|
20
|
+
"dirigent-plugin==0.18.2",
|
|
21
|
+
"dirigent-server==0.18.2",
|
|
22
22
|
"httpx2>=2.12.0",
|
|
23
23
|
"python-dotenv>=1.1.0",
|
|
24
24
|
"pyyaml>=6.0.3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-cli"
|
|
3
|
-
version = "0.18.
|
|
3
|
+
version = "0.18.2"
|
|
4
4
|
description = "The dirigent command line interface (dirigent / dg)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -11,14 +11,14 @@ classifiers = [
|
|
|
11
11
|
"Programming Language :: Python :: 3.13",
|
|
12
12
|
]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"dirigent-block-execute==0.18.
|
|
15
|
-
"dirigent-blocks==0.18.
|
|
16
|
-
"dirigent-client==0.18.
|
|
17
|
-
"dirigent-common==0.18.
|
|
18
|
-
"dirigent-core==0.18.
|
|
19
|
-
"dirigent-examples==0.18.
|
|
20
|
-
"dirigent-plugin==0.18.
|
|
21
|
-
"dirigent-server==0.18.
|
|
14
|
+
"dirigent-block-execute==0.18.2",
|
|
15
|
+
"dirigent-blocks==0.18.2",
|
|
16
|
+
"dirigent-client==0.18.2",
|
|
17
|
+
"dirigent-common==0.18.2",
|
|
18
|
+
"dirigent-core==0.18.2",
|
|
19
|
+
"dirigent-examples==0.18.2",
|
|
20
|
+
"dirigent-plugin==0.18.2",
|
|
21
|
+
"dirigent-server==0.18.2",
|
|
22
22
|
"httpx2>=2.12.0",
|
|
23
23
|
"python-dotenv>=1.1.0",
|
|
24
24
|
"pyyaml>=6.0.3",
|
|
@@ -15,6 +15,7 @@ from rich.markup import escape
|
|
|
15
15
|
|
|
16
16
|
from dirigent_cli import schemas, starters
|
|
17
17
|
from dirigent_cli.context import CliState, Session, client_for, state_of
|
|
18
|
+
from dirigent_cli.explain import explain as document_shape
|
|
18
19
|
from dirigent_cli.graph import GraphStep, render_graph, steps_of_document
|
|
19
20
|
from dirigent_cli.local import (
|
|
20
21
|
ConnectionSpec,
|
|
@@ -61,6 +62,7 @@ from dirigent_cli.messages import (
|
|
|
61
62
|
NOT_A_RUN_STATUS,
|
|
62
63
|
NOT_A_STARTER,
|
|
63
64
|
NOT_A_WINDOW,
|
|
65
|
+
NOT_AN_IMPORTANCE,
|
|
64
66
|
NOT_AUTHENTICATED,
|
|
65
67
|
OR_A_PROFILE_TOKEN,
|
|
66
68
|
OR_DG_AUTH_LOGIN,
|
|
@@ -104,6 +106,7 @@ from dirigent_cli.output import (
|
|
|
104
106
|
prioritised,
|
|
105
107
|
refuse,
|
|
106
108
|
render_bool,
|
|
109
|
+
render_check,
|
|
107
110
|
stream_line,
|
|
108
111
|
styled,
|
|
109
112
|
table,
|
|
@@ -140,6 +143,7 @@ from dirigent_client import (
|
|
|
140
143
|
DocumentKind,
|
|
141
144
|
ExampleDetail,
|
|
142
145
|
ExampleOut,
|
|
146
|
+
Importance,
|
|
143
147
|
ItemOut,
|
|
144
148
|
LogEntryOut,
|
|
145
149
|
LogLevel,
|
|
@@ -467,11 +471,18 @@ def validate_command(
|
|
|
467
471
|
server: Annotated[
|
|
468
472
|
bool, typer.Option("--server", help="Also check it against a server's catalog and requirements.")
|
|
469
473
|
] = False,
|
|
474
|
+
explain: Annotated[
|
|
475
|
+
bool, typer.Option("--explain", help="Also say what the work will cost: how wide, how many tries, how long.")
|
|
476
|
+
] = False,
|
|
470
477
|
) -> None:
|
|
471
478
|
"""Check a document offline, or against a server's catalog too with --server.
|
|
472
479
|
|
|
473
480
|
Offline, a triggers document is checked at the schema level: its clocks and its codes.
|
|
474
481
|
`--server` adds the pipeline it names and the parameters its schedules pin.
|
|
482
|
+
|
|
483
|
+
`--explain` adds the shape of the work ahead for every pipeline document that is valid:
|
|
484
|
+
the fan-out widths, the retry budgets and the waits. With `--server` too, a sensor that
|
|
485
|
+
declares neither cadence nor deadline takes the block's own.
|
|
475
486
|
"""
|
|
476
487
|
state = state_of(ctx)
|
|
477
488
|
documents = _documents_to_apply(reference, None)
|
|
@@ -524,16 +535,26 @@ def validate_command(
|
|
|
524
535
|
)
|
|
525
536
|
failures += 1
|
|
526
537
|
continue
|
|
538
|
+
checked = "document and catalog" if catalog is not None else "document, offline"
|
|
527
539
|
emit_fact(
|
|
528
540
|
"validation",
|
|
529
541
|
message="valid",
|
|
530
542
|
code=definition.code,
|
|
531
543
|
document=label,
|
|
532
|
-
checked=
|
|
544
|
+
checked=checked,
|
|
533
545
|
steps=[step._asdict() for step in graph_steps(definition)]
|
|
534
546
|
if isinstance(definition, PipelineDefinition)
|
|
535
547
|
else None,
|
|
536
548
|
)
|
|
549
|
+
if explain and isinstance(definition, PipelineDefinition):
|
|
550
|
+
emit_fact(
|
|
551
|
+
"validation.shape",
|
|
552
|
+
message="shape",
|
|
553
|
+
code=definition.code,
|
|
554
|
+
document=label,
|
|
555
|
+
checked=checked,
|
|
556
|
+
**document_shape(definition, catalog).model_dump(mode="json"),
|
|
557
|
+
)
|
|
537
558
|
emit_fact(
|
|
538
559
|
"validated",
|
|
539
560
|
level="error" if failures else "info",
|
|
@@ -831,6 +852,7 @@ def pipeline_show(
|
|
|
831
852
|
"name": row.name or "-",
|
|
832
853
|
"description": row.description or "-",
|
|
833
854
|
"tags": " ".join(row.tags) or "-",
|
|
855
|
+
"importance": row.importance.value,
|
|
834
856
|
"active": "yes" if row.active else "no",
|
|
835
857
|
"current version": row.current_version,
|
|
836
858
|
"runs in flight": row.active_runs,
|
|
@@ -1049,6 +1071,20 @@ def parse_priority(value: str | None) -> RunPriority | None:
|
|
|
1049
1071
|
)
|
|
1050
1072
|
|
|
1051
1073
|
|
|
1074
|
+
def parse_importance(value: str | None) -> Importance | None:
|
|
1075
|
+
"""Read the importance a rule was given, naming the vocabulary when it is not one."""
|
|
1076
|
+
if value is None:
|
|
1077
|
+
return None
|
|
1078
|
+
try:
|
|
1079
|
+
return Importance(value.lower())
|
|
1080
|
+
except ValueError:
|
|
1081
|
+
fail(
|
|
1082
|
+
NOT_AN_IMPORTANCE,
|
|
1083
|
+
value=repr(value),
|
|
1084
|
+
allowed=", ".join(importance.value for importance in Importance),
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
|
|
1052
1088
|
def run_command(
|
|
1053
1089
|
ctx: typer.Context,
|
|
1054
1090
|
target: Annotated[str, typer.Argument(help="A pipeline code, or a document file, URL, or -.")],
|
|
@@ -2285,7 +2321,7 @@ def connection_list(
|
|
|
2285
2321
|
row.name or "-",
|
|
2286
2322
|
row.kind,
|
|
2287
2323
|
moment(row.last_check_at),
|
|
2288
|
-
|
|
2324
|
+
render_check(row.last_check_at, row.last_check_healthy),
|
|
2289
2325
|
row.description or "-",
|
|
2290
2326
|
]
|
|
2291
2327
|
for row in rows
|
|
@@ -2389,18 +2425,21 @@ def connection_show(
|
|
|
2389
2425
|
|
|
2390
2426
|
@connection_app.command("check")
|
|
2391
2427
|
def connection_check(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
|
|
2392
|
-
"""Ask a connection's own kind whether its external system answers.
|
|
2428
|
+
"""Ask a connection's own kind whether its external system answers.
|
|
2429
|
+
|
|
2430
|
+
A check that ran and could not decide is not a failure, so only a refused one exits 1.
|
|
2431
|
+
"""
|
|
2393
2432
|
with client_for(state_of(ctx)) as dg:
|
|
2394
2433
|
report = dg.call(dg.connections.check(code))
|
|
2395
2434
|
emit_fact(
|
|
2396
2435
|
"connection.checked",
|
|
2397
|
-
message="healthy" if report.healthy else "unhealthy",
|
|
2436
|
+
message="not verified" if report.healthy is None else "healthy" if report.healthy else "unhealthy",
|
|
2398
2437
|
code=code,
|
|
2399
2438
|
healthy=report.healthy,
|
|
2400
2439
|
detail=report.detail,
|
|
2401
2440
|
version=report.version,
|
|
2402
2441
|
)
|
|
2403
|
-
if
|
|
2442
|
+
if report.healthy is False:
|
|
2404
2443
|
raise typer.Exit(code=1)
|
|
2405
2444
|
|
|
2406
2445
|
|
|
@@ -2541,7 +2580,7 @@ def system_info(
|
|
|
2541
2580
|
row.name or "-",
|
|
2542
2581
|
row.kind,
|
|
2543
2582
|
moment(row.last_check_at),
|
|
2544
|
-
|
|
2583
|
+
render_check(row.last_check_at, row.last_check_healthy),
|
|
2545
2584
|
row.last_check_detail or "-",
|
|
2546
2585
|
]
|
|
2547
2586
|
for row in info.connections
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""What a document will cost before it runs: how wide, how many tries, how long it may wait.
|
|
2
|
+
|
|
3
|
+
Everything here is read off the document, and off the catalog where a server hands one over.
|
|
4
|
+
Nothing executes, so the numbers are the ones the engine fixes when it creates a run: a
|
|
5
|
+
fan-out's cardinality, the attempts a retry policy allows, and the waits a step may spend
|
|
6
|
+
under a deadline. What only the run can decide -- a parameter supplied at the command line,
|
|
7
|
+
a window's bounds -- is reported as unknown rather than guessed at, and a total that counted
|
|
8
|
+
one of those says so.
|
|
9
|
+
|
|
10
|
+
The cardinality rules are :func:`dirigent_core.engine.runs.resolve_fan_out`'s: a literal list
|
|
11
|
+
is its own length, ``${steps.<name>.items}`` adopts another fan-out's grid, a reference that
|
|
12
|
+
resolves to a list is that list, and anything else is only the run's to answer.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
from dirigent_cli.schemas import ShapeWarning, StepShape
|
|
22
|
+
from dirigent_client.schemas import BlockEntry, BlockKind, Catalog
|
|
23
|
+
from dirigent_common import Duration, JsonMap, base_format_checker, format_duration
|
|
24
|
+
from dirigent_core.engine.definition import ParameterError, PipelineDefinition, RetryPolicy, StepDefinition
|
|
25
|
+
from dirigent_core.engine.failure import backoff_delay
|
|
26
|
+
from dirigent_core.engine.references import ReferenceScope, UnknownReference, resolve
|
|
27
|
+
|
|
28
|
+
#: What a cardinality says when the document alone cannot fix it.
|
|
29
|
+
UNKNOWN: Final = "unknown"
|
|
30
|
+
|
|
31
|
+
#: How a cardinality names the fan-out whose grid a step maps over instead of its own.
|
|
32
|
+
ADOPTS: Final = "adopts {step}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DocumentShape(BaseModel):
|
|
36
|
+
"""What a whole document will cost: the steps, the totals over them, and the warnings."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(frozen=True)
|
|
39
|
+
|
|
40
|
+
attempts_max: int = 0
|
|
41
|
+
"""Every attempt the document allows: each step's tries times the items it maps over."""
|
|
42
|
+
|
|
43
|
+
attempts_at_least: bool = False
|
|
44
|
+
"""Whether that total is a floor, a cardinality only the run can fix having counted once."""
|
|
45
|
+
|
|
46
|
+
deadline_longest: Duration | None = None
|
|
47
|
+
"""The longest chain of deadlines through the DAG, which is the wait the document allows."""
|
|
48
|
+
|
|
49
|
+
steps: list[StepShape] = Field(default_factory=list[StepShape])
|
|
50
|
+
warnings: list[ShapeWarning] = Field(default_factory=list[ShapeWarning])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def explain(definition: PipelineDefinition, catalog: Catalog | None = None) -> DocumentShape:
|
|
54
|
+
"""Read a document as the work it will become, in the order the steps will run.
|
|
55
|
+
|
|
56
|
+
``catalog`` is a server's, where one was asked for: a sensor that declares no cadence and
|
|
57
|
+
no deadline of its own takes the block's, and the row says which of the two came from
|
|
58
|
+
there. With no catalog the document's own values are all there is.
|
|
59
|
+
"""
|
|
60
|
+
params = _param_defaults(definition)
|
|
61
|
+
order = definition.topological_order()
|
|
62
|
+
shapes: dict[str, StepShape] = {}
|
|
63
|
+
for name in order:
|
|
64
|
+
shapes[name] = _step_shape(name, definition.steps[name], params, shapes, catalog)
|
|
65
|
+
rows = [shapes[name] for name in order]
|
|
66
|
+
return DocumentShape(
|
|
67
|
+
attempts_max=sum(row.max_attempts * (1 if row.elements is None else row.elements) for row in rows),
|
|
68
|
+
attempts_at_least=any(row.elements is None for row in rows),
|
|
69
|
+
deadline_longest=_deadline_longest(definition, shapes, order),
|
|
70
|
+
steps=rows,
|
|
71
|
+
warnings=_warnings(rows, catalog),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _param_defaults(definition: PipelineDefinition) -> JsonMap:
|
|
76
|
+
"""Fill the parameters a run would be given with the defaults the document declares.
|
|
77
|
+
|
|
78
|
+
A parameter with no default is one only the run supplies, and every cardinality that
|
|
79
|
+
reads one is unknown here rather than wrong.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
return definition.validate_params({}, base_format_checker())
|
|
83
|
+
except ParameterError:
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _step_shape(
|
|
88
|
+
name: str,
|
|
89
|
+
step: StepDefinition,
|
|
90
|
+
params: JsonMap,
|
|
91
|
+
done: Mapping[str, StepShape],
|
|
92
|
+
catalog: Catalog | None,
|
|
93
|
+
) -> StepShape:
|
|
94
|
+
"""Read one step as the work it will become, against the steps already read."""
|
|
95
|
+
cardinality, elements = _cardinality(step, params, done)
|
|
96
|
+
poll, deadline, from_block = _waits(step, _entry(step, catalog))
|
|
97
|
+
return StepShape(
|
|
98
|
+
name=name,
|
|
99
|
+
block=step.block,
|
|
100
|
+
depends_on=list(step.depends_on),
|
|
101
|
+
cardinality=cardinality,
|
|
102
|
+
elements=elements,
|
|
103
|
+
reference=step.for_each if isinstance(step.for_each, str) else None,
|
|
104
|
+
items=step.items.value,
|
|
105
|
+
max_attempts=step.retry.max_attempts,
|
|
106
|
+
retry_wait=_retry_wait(step.retry),
|
|
107
|
+
timeout=step.timeout,
|
|
108
|
+
poll=poll,
|
|
109
|
+
deadline=deadline,
|
|
110
|
+
on_timeout=step.on_timeout.value,
|
|
111
|
+
from_block=from_block,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cardinality(step: StepDefinition, params: JsonMap, done: Mapping[str, StepShape]) -> tuple[int | str, int | None]:
|
|
116
|
+
"""Say how many run items a step becomes, and the count behind that where there is one."""
|
|
117
|
+
expression = step.for_each
|
|
118
|
+
if expression is None:
|
|
119
|
+
return 1, 1
|
|
120
|
+
if isinstance(expression, list):
|
|
121
|
+
return len(expression), len(expression)
|
|
122
|
+
adopted = step.adopted_grid
|
|
123
|
+
if adopted is not None:
|
|
124
|
+
upstream = done[adopted].elements if adopted in done else None
|
|
125
|
+
return ADOPTS.format(step=adopted), upstream
|
|
126
|
+
if "steps." in expression:
|
|
127
|
+
# resolve_fan_out refuses a step's output here, because cardinality is fixed when the
|
|
128
|
+
# run is created and no step has run by then.
|
|
129
|
+
return UNKNOWN, None
|
|
130
|
+
try:
|
|
131
|
+
resolved = resolve(expression, ReferenceScope(params=params))
|
|
132
|
+
except UnknownReference:
|
|
133
|
+
return UNKNOWN, None
|
|
134
|
+
if not isinstance(resolved, list):
|
|
135
|
+
return UNKNOWN, None
|
|
136
|
+
return len(resolved), len(resolved)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _entry(step: StepDefinition, catalog: Catalog | None) -> BlockEntry | None:
|
|
140
|
+
"""Find what the catalog says about this step's block, where there is a catalog."""
|
|
141
|
+
return None if catalog is None else catalog.block(step.block)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _waits(step: StepDefinition, entry: BlockEntry | None) -> tuple[timedelta | None, timedelta | None, list[str]]:
|
|
145
|
+
"""Take the cadence and the deadline from the document, then from the block's own defaults."""
|
|
146
|
+
poll, deadline = step.poll, step.deadline
|
|
147
|
+
from_block: list[str] = []
|
|
148
|
+
if entry is None:
|
|
149
|
+
return poll, deadline, from_block
|
|
150
|
+
if poll is None and entry.default_poll_seconds is not None:
|
|
151
|
+
poll = timedelta(seconds=entry.default_poll_seconds)
|
|
152
|
+
from_block.append("poll")
|
|
153
|
+
if deadline is None and entry.default_deadline_seconds is not None:
|
|
154
|
+
deadline = timedelta(seconds=entry.default_deadline_seconds)
|
|
155
|
+
from_block.append("deadline")
|
|
156
|
+
return poll, deadline, from_block
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _retry_wait(policy: RetryPolicy) -> timedelta | None:
|
|
160
|
+
"""Add up every backoff the policy allows, at the top of the jitter it spreads them over.
|
|
161
|
+
|
|
162
|
+
``max_attempts`` tries have one delay fewer between them, and each delay is the engine's
|
|
163
|
+
own: exponential, clamped at ``max_backoff``, and then spread by the jitter.
|
|
164
|
+
"""
|
|
165
|
+
if policy.max_attempts <= 1:
|
|
166
|
+
return None
|
|
167
|
+
steady = policy.model_copy(update={"jitter": 0.0})
|
|
168
|
+
total = sum((backoff_delay(steady, attempt) for attempt in range(1, policy.max_attempts)), timedelta(0))
|
|
169
|
+
return total * (1 + policy.jitter)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _deadline_longest(
|
|
173
|
+
definition: PipelineDefinition, shapes: Mapping[str, StepShape], order: Sequence[str]
|
|
174
|
+
) -> timedelta | None:
|
|
175
|
+
"""Add the deadlines along each path through the DAG and take the longest of them.
|
|
176
|
+
|
|
177
|
+
A step cannot start before its dependencies settled, so the chain is what the document
|
|
178
|
+
allows end to end; a step with no deadline lengthens no chain.
|
|
179
|
+
"""
|
|
180
|
+
chains: dict[str, timedelta] = {}
|
|
181
|
+
for name in order:
|
|
182
|
+
upstream = max((chains[one] for one in definition.steps[name].depends_on), default=timedelta(0))
|
|
183
|
+
chains[name] = upstream + (shapes[name].deadline or timedelta(0))
|
|
184
|
+
longest = max(chains.values(), default=timedelta(0))
|
|
185
|
+
return longest or None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _warnings(rows: Sequence[StepShape], catalog: Catalog | None) -> list[ShapeWarning]:
|
|
189
|
+
"""Name what the document leaves unbounded or unresolved, one sentence each."""
|
|
190
|
+
found: list[ShapeWarning] = []
|
|
191
|
+
for row in rows:
|
|
192
|
+
# The step that named the reference is warned about; one that adopts its grid has the
|
|
193
|
+
# same unknown width and no second cause.
|
|
194
|
+
if row.cardinality == UNKNOWN:
|
|
195
|
+
found.append(
|
|
196
|
+
ShapeWarning(
|
|
197
|
+
step=row.name,
|
|
198
|
+
cause="unknown-cardinality",
|
|
199
|
+
message=(
|
|
200
|
+
f"{row.name} fans out over {row.reference}, which only the run can resolve, "
|
|
201
|
+
f"so it is counted as one item here."
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
if row.retry_wait is not None and row.deadline is not None and row.retry_wait > row.deadline:
|
|
206
|
+
found.append(
|
|
207
|
+
ShapeWarning(
|
|
208
|
+
step=row.name,
|
|
209
|
+
cause="retry-outlasts-deadline",
|
|
210
|
+
message=(
|
|
211
|
+
f"{row.name} may spend {format_duration(row.retry_wait)} in backoff between its "
|
|
212
|
+
f"{row.max_attempts} attempts, which is longer than the {format_duration(row.deadline)} "
|
|
213
|
+
f"deadline each one waits under."
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
if row.deadline is None and _is_sensor(row, catalog):
|
|
218
|
+
found.append(ShapeWarning(step=row.name, cause="unbounded-sensor", message=_unbounded(row, catalog)))
|
|
219
|
+
return found
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _unbounded(row: StepShape, catalog: Catalog | None) -> str:
|
|
223
|
+
"""Say that nothing bounds a sensor's wait, and where a catalog would still fill one in."""
|
|
224
|
+
if catalog is None:
|
|
225
|
+
return f"{row.name} polls with no deadline, so nothing here bounds its wait: --server reads the block's."
|
|
226
|
+
return f"{row.name} waits on a sensor that neither the document nor {row.block} gives a deadline."
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _is_sensor(row: StepShape, catalog: Catalog | None) -> bool:
|
|
230
|
+
"""Report whether a step waits on a sensor, by the catalog where there is one.
|
|
231
|
+
|
|
232
|
+
Offline there is nothing to ask, and a cadence is the one thing only a sensor carries.
|
|
233
|
+
"""
|
|
234
|
+
if catalog is None:
|
|
235
|
+
return row.poll is not None
|
|
236
|
+
entry = catalog.block(row.block)
|
|
237
|
+
return entry is not None and entry.kind is BlockKind.SENSOR
|
|
@@ -52,6 +52,7 @@ from dirigent_common import Issue
|
|
|
52
52
|
from dirigent_core import migrations
|
|
53
53
|
from dirigent_core.config import STATE_DIR, Settings, get_settings, redacted_url, reset_settings_cache
|
|
54
54
|
from dirigent_core.logging import configure_logging, silence_stdout
|
|
55
|
+
from dirigent_core.messages import SCHEMA_STALE
|
|
55
56
|
from dirigent_core.protocol import FORMATS, Format, Record, make
|
|
56
57
|
from dirigent_core.telemetry import configure_telemetry
|
|
57
58
|
|
|
@@ -64,6 +65,7 @@ if TYPE_CHECKING:
|
|
|
64
65
|
from dirigent_cli.stream import Sink
|
|
65
66
|
from dirigent_common import JsonMap
|
|
66
67
|
from dirigent_core import retention
|
|
68
|
+
from dirigent_core.migrations import SchemaDifference
|
|
67
69
|
from dirigent_core.worker import Worker
|
|
68
70
|
|
|
69
71
|
#: Help is capped rather than stretched: a panel the width of a wide terminal is unreadable.
|
|
@@ -703,6 +705,7 @@ def server(
|
|
|
703
705
|
problems=[Issue.of(USE_DG_DEV_STANDALONE), Issue.of(OR_NO_SCHEDULER), Issue.of(OR_POSTGRES)],
|
|
704
706
|
)
|
|
705
707
|
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
708
|
+
guard_schema(settings)
|
|
706
709
|
uvicorn.run(
|
|
707
710
|
"dirigent_cli.main:build_app",
|
|
708
711
|
factory=True,
|
|
@@ -912,6 +915,7 @@ def dev(
|
|
|
912
915
|
if cleared is not None:
|
|
913
916
|
emit(state_cleared(cleared))
|
|
914
917
|
migrated = _migrate_quietly(settings)
|
|
918
|
+
guard_schema(settings)
|
|
915
919
|
admin, token = asyncio.run(dev_admin(settings))
|
|
916
920
|
address = host or settings.host
|
|
917
921
|
listening = port or settings.port
|
|
@@ -966,6 +970,47 @@ def _migrate_quietly(settings: Settings) -> str | None:
|
|
|
966
970
|
return head
|
|
967
971
|
|
|
968
972
|
|
|
973
|
+
async def _schema_differences(settings: Settings) -> list["SchemaDifference"]:
|
|
974
|
+
"""Read the live schema once, and dispose of the engine that read it."""
|
|
975
|
+
from dirigent_core.database import create_engine
|
|
976
|
+
|
|
977
|
+
engine = create_engine(settings)
|
|
978
|
+
try:
|
|
979
|
+
return await migrations.schema_differences(engine)
|
|
980
|
+
finally:
|
|
981
|
+
await engine.dispose()
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def guard_schema(settings: Settings) -> None:
|
|
985
|
+
"""Refuse a database whose schema is not the one this dirigent's models describe.
|
|
986
|
+
|
|
987
|
+
Before 1.0 the baseline migration is edited in place, so a state an older dirigent wrote
|
|
988
|
+
is stamped at the same revision and there is nothing for an upgrade to do. Reflection is
|
|
989
|
+
what answers instead, and a process given such a file would otherwise start, say ready,
|
|
990
|
+
and fail every query it ever ran.
|
|
991
|
+
"""
|
|
992
|
+
import asyncio
|
|
993
|
+
|
|
994
|
+
from dirigent_cli.health import where_database
|
|
995
|
+
|
|
996
|
+
# A database nothing has ever migrated is a different fault, which this refusal and its
|
|
997
|
+
# remedies would name wrongly.
|
|
998
|
+
if migrations.current_revision(settings) is None:
|
|
999
|
+
return
|
|
1000
|
+
differences = asyncio.run(_schema_differences(settings))
|
|
1001
|
+
if not differences:
|
|
1002
|
+
return
|
|
1003
|
+
refuse(
|
|
1004
|
+
SCHEMA_STALE,
|
|
1005
|
+
status=commands.GUARD_EXIT,
|
|
1006
|
+
title="A state an older dirigent wrote",
|
|
1007
|
+
where=where_database(settings),
|
|
1008
|
+
differences=len(differences),
|
|
1009
|
+
first=str(differences[0]),
|
|
1010
|
+
)
|
|
1011
|
+
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
1012
|
+
|
|
1013
|
+
|
|
969
1014
|
def dev_started(
|
|
970
1015
|
settings: Settings, *, bound: str, admin: str | None, token: str | None, migrated: str | None
|
|
971
1016
|
) -> Record:
|
|
@@ -1212,6 +1257,7 @@ def worker(
|
|
|
1212
1257
|
problems=[Issue.of(USE_DG_DEV), Issue.of(OR_POSTGRES)],
|
|
1213
1258
|
)
|
|
1214
1259
|
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
1260
|
+
guard_schema(settings)
|
|
1215
1261
|
asyncio.run(_worker(settings, concurrency, tag, name))
|
|
1216
1262
|
|
|
1217
1263
|
|
|
@@ -1247,6 +1293,7 @@ def scheduler_command(ctx: typer.Context) -> None:
|
|
|
1247
1293
|
problems=[Issue.of(USE_DG_DEV), Issue.of(OR_POSTGRES)],
|
|
1248
1294
|
)
|
|
1249
1295
|
raise typer.Exit(code=commands.GUARD_EXIT)
|
|
1296
|
+
guard_schema(settings)
|
|
1250
1297
|
emit(
|
|
1251
1298
|
make(
|
|
1252
1299
|
"process",
|
|
@@ -1308,7 +1355,7 @@ VALUE_OPTIONS = frozenset(
|
|
|
1308
1355
|
"--concurrency",
|
|
1309
1356
|
"--tag",
|
|
1310
1357
|
"--event",
|
|
1311
|
-
"--
|
|
1358
|
+
"--connection",
|
|
1312
1359
|
"--status",
|
|
1313
1360
|
"--pipeline",
|
|
1314
1361
|
"--since",
|
|
@@ -217,6 +217,8 @@ MAP_TAKES_A_PATH = CLI.define(
|
|
|
217
217
|
|
|
218
218
|
NOT_AN_ALERT_EVENT = CLI.define("not_an_alert_event", "{event} is not an alert event ({allowed})")
|
|
219
219
|
|
|
220
|
+
NOT_AN_IMPORTANCE = CLI.define("not_an_importance", "--importance {value} is not an importance ({allowed})")
|
|
221
|
+
|
|
220
222
|
ONE_BODY = CLI.define("one_body", "a rule takes one body: --body or --body-file, not both")
|
|
221
223
|
|
|
222
224
|
SCAFFOLD_REFUSED = CLI.define("scaffold_refused", "{detail}")
|
|
@@ -19,7 +19,7 @@ from rich.table import Table
|
|
|
19
19
|
from rich.text import Text
|
|
20
20
|
|
|
21
21
|
from dirigent_client.schemas.common import Problem
|
|
22
|
-
from dirigent_common import Issue, Message
|
|
22
|
+
from dirigent_common import DurationError, Issue, Message, format_duration, to_timedelta
|
|
23
23
|
from dirigent_core.protocol import Format, Record, as_json, make
|
|
24
24
|
|
|
25
25
|
#: Whether the environment asked for no colour. Rich decides colour when a console is built,
|
|
@@ -474,6 +474,42 @@ def prioritised(priority: object) -> str:
|
|
|
474
474
|
return ""
|
|
475
475
|
|
|
476
476
|
|
|
477
|
+
def watching(scope: object, importance: object) -> str:
|
|
478
|
+
"""What an alert rule watches, in one cell: its scope, and the floor it fires at.
|
|
479
|
+
|
|
480
|
+
Almost every rule names no importance, so a column of its own would mostly be empty.
|
|
481
|
+
"""
|
|
482
|
+
return f"{scope}, {importance} and above" if importance else str(scope)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
#: What each alert event is called in a rendering. The wire's word is what a rule is declared
|
|
486
|
+
#: with; these are the words a reader is shown, here and on the web UI's own listing.
|
|
487
|
+
ALERT_EVENTS: Mapping[str, str] = {
|
|
488
|
+
"run_failed": "Failed",
|
|
489
|
+
"run_completed_with_errors": "Completed with errors",
|
|
490
|
+
"run_succeeded": "Succeeded",
|
|
491
|
+
"run_stuck": "Stuck",
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def alert_event(event: object) -> str:
|
|
496
|
+
"""Render what a rule watches for, falling back to the wire's word for an unknown event."""
|
|
497
|
+
return ALERT_EVENTS.get(str(event), str(event))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def throttle_note(window: object) -> str:
|
|
501
|
+
"""Render a rule's throttle, and of an unthrottled one that it is not throttled.
|
|
502
|
+
|
|
503
|
+
A zero is the absence of a window rather than a very short one, and a column of durations
|
|
504
|
+
is read as durations. A duration this build cannot read is left as the server spelled it.
|
|
505
|
+
"""
|
|
506
|
+
try:
|
|
507
|
+
held = to_timedelta(window)
|
|
508
|
+
except DurationError:
|
|
509
|
+
return str(window)
|
|
510
|
+
return format_duration(held) if held else "none"
|
|
511
|
+
|
|
512
|
+
|
|
477
513
|
def status_cell(value: object, width: int = STATUS_WIDTH) -> str:
|
|
478
514
|
"""Render a status padded to a fixed width, then coloured.
|
|
479
515
|
|
|
@@ -493,6 +529,19 @@ def render_bool(value: object) -> str:
|
|
|
493
529
|
return "[green]yes[/]" if value else "[dim]no[/]"
|
|
494
530
|
|
|
495
531
|
|
|
532
|
+
def render_check(last_check_at: object, healthy: object) -> str:
|
|
533
|
+
"""Render what a connection's last check said, across the four states a row can be in.
|
|
534
|
+
|
|
535
|
+
A check answers yes, no, or that it could not decide; a row nothing has checked answers
|
|
536
|
+
none of the three.
|
|
537
|
+
"""
|
|
538
|
+
if not last_check_at:
|
|
539
|
+
return "-"
|
|
540
|
+
if healthy is None:
|
|
541
|
+
return "[dim]not verified[/]"
|
|
542
|
+
return "[green]yes[/]" if healthy else "[dim]no[/]"
|
|
543
|
+
|
|
544
|
+
|
|
496
545
|
def moment(value: object) -> str:
|
|
497
546
|
"""Render a timestamp compactly, in the local time an operator is reading it in."""
|
|
498
547
|
if not value:
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
"""What the
|
|
1
|
+
"""What the rows inside a record carry: a run's steps, a backfill's windows, a document's shape.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Records are written through :mod:`dirigent_core.protocol`; these are the shapes of the
|
|
4
|
+
collections one carries beneath its line. They are the contract a script filters on, and they
|
|
5
|
+
are also what the table under that line is rendered from, so a field the rendering needs
|
|
6
|
+
belongs here rather than in a second query.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from uuid import UUID
|
|
10
10
|
|
|
11
11
|
from pydantic import BaseModel, ConfigDict, Field
|
|
12
12
|
|
|
13
|
-
from dirigent_common import JsonMap
|
|
13
|
+
from dirigent_common import Duration, JsonMap
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class StepSummary(BaseModel):
|
|
@@ -53,6 +53,51 @@ class FailureSummary(BaseModel):
|
|
|
53
53
|
"""What the attempt was given, which is half of why it failed."""
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
class StepShape(BaseModel):
|
|
57
|
+
"""What one step of a checked document will become, before anything has run."""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(frozen=True)
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
"""The step's own map key, which is what every reference to it is written with."""
|
|
63
|
+
|
|
64
|
+
block: str
|
|
65
|
+
depends_on: list[str] = Field(default_factory=list[str])
|
|
66
|
+
|
|
67
|
+
cardinality: int | str = 1
|
|
68
|
+
"""How many run items this step becomes: a count, ``adopts <step>``, or ``unknown``."""
|
|
69
|
+
|
|
70
|
+
elements: int | None = 1
|
|
71
|
+
"""The count behind the cardinality, which an adoption takes from the grid it adopts."""
|
|
72
|
+
|
|
73
|
+
reference: str | None = None
|
|
74
|
+
"""The ``for_each`` this step maps over, where it names one rather than listing it."""
|
|
75
|
+
|
|
76
|
+
items: str = "fail_fast"
|
|
77
|
+
max_attempts: int = 1
|
|
78
|
+
|
|
79
|
+
retry_wait: Duration | None = None
|
|
80
|
+
"""The longest this step may spend in backoff, which is every delay its policy allows."""
|
|
81
|
+
|
|
82
|
+
timeout: Duration | None = None
|
|
83
|
+
poll: Duration | None = None
|
|
84
|
+
deadline: Duration | None = None
|
|
85
|
+
on_timeout: str = "fail"
|
|
86
|
+
|
|
87
|
+
from_block: list[str] = Field(default_factory=list[str])
|
|
88
|
+
"""Which of this row's waits the block's own defaults filled, the document declaring none."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ShapeWarning(BaseModel):
|
|
92
|
+
"""One thing about a document's shape worth knowing before the run, said as a sentence."""
|
|
93
|
+
|
|
94
|
+
model_config = ConfigDict(frozen=True)
|
|
95
|
+
|
|
96
|
+
step: str
|
|
97
|
+
cause: str
|
|
98
|
+
message: str
|
|
99
|
+
|
|
100
|
+
|
|
56
101
|
class BackfillWindow(BaseModel):
|
|
57
102
|
"""One window a backfill enumerated, as the closing backfill record carries it."""
|
|
58
103
|
|
|
@@ -10,6 +10,7 @@ next week has nothing else to consult either.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
from datetime import timedelta
|
|
13
14
|
from typing import Final, cast
|
|
14
15
|
|
|
15
16
|
from pydantic import BaseModel
|
|
@@ -31,6 +32,7 @@ from dirigent_cli.output import (
|
|
|
31
32
|
render_output,
|
|
32
33
|
styled,
|
|
33
34
|
)
|
|
35
|
+
from dirigent_common import format_duration, to_timedelta
|
|
34
36
|
from dirigent_core.protocol import Record
|
|
35
37
|
|
|
36
38
|
#: Fields a record carries for what is drawn beneath its line rather than for the line. A
|
|
@@ -39,6 +41,7 @@ BULKY: Final = frozenset(
|
|
|
39
41
|
{
|
|
40
42
|
"steps",
|
|
41
43
|
"failures",
|
|
44
|
+
"warnings",
|
|
42
45
|
"windows",
|
|
43
46
|
"packages",
|
|
44
47
|
"settings",
|
|
@@ -395,6 +398,78 @@ def _graph_step(one: Mapping[str, object]) -> GraphStep:
|
|
|
395
398
|
)
|
|
396
399
|
|
|
397
400
|
|
|
401
|
+
def _shape(record: Record) -> RenderableType | None:
|
|
402
|
+
"""Render what a document will cost: each step's shape, the totals, and the warnings."""
|
|
403
|
+
steps: list[schemas.StepShape] = _rows(record, "steps", schemas.StepShape)
|
|
404
|
+
if not steps:
|
|
405
|
+
return None
|
|
406
|
+
parts: list[RenderableType] = [
|
|
407
|
+
build_table(
|
|
408
|
+
f"what {record.get('code')} will cost",
|
|
409
|
+
["step", "block", "cardinality", "attempts", "retry wait", "timeout", "deadline", "poll"],
|
|
410
|
+
[
|
|
411
|
+
[
|
|
412
|
+
escape(row.name),
|
|
413
|
+
escape(row.block),
|
|
414
|
+
_width(row),
|
|
415
|
+
str(row.max_attempts),
|
|
416
|
+
_wait(row.retry_wait),
|
|
417
|
+
_wait(row.timeout),
|
|
418
|
+
_deadline(row),
|
|
419
|
+
_wait(row.poll, from_block="poll" in row.from_block),
|
|
420
|
+
]
|
|
421
|
+
for row in steps
|
|
422
|
+
],
|
|
423
|
+
),
|
|
424
|
+
"",
|
|
425
|
+
build_fields(
|
|
426
|
+
"the whole document",
|
|
427
|
+
{
|
|
428
|
+
"steps": len(steps),
|
|
429
|
+
"attempts": _attempts(record),
|
|
430
|
+
"deadline chain": _wait(_duration(record, "deadline_longest")),
|
|
431
|
+
},
|
|
432
|
+
),
|
|
433
|
+
]
|
|
434
|
+
warned = _rows(record, "warnings", schemas.ShapeWarning)
|
|
435
|
+
if warned:
|
|
436
|
+
parts.append("")
|
|
437
|
+
parts.extend(f" [yellow]-[/] {escape(one.message)}" for one in warned)
|
|
438
|
+
return Group(*parts)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _width(row: schemas.StepShape) -> str:
|
|
442
|
+
"""Say how wide a step is, and where one bad item leaves the rest of the batch running."""
|
|
443
|
+
drawn = escape(str(row.cardinality))
|
|
444
|
+
return f"{drawn} {muted('continue')}" if row.items == "continue" else drawn
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _deadline(row: schemas.StepShape) -> str:
|
|
448
|
+
"""Say how long a step may wait, and what an expired deadline makes of it."""
|
|
449
|
+
drawn = _wait(row.deadline, from_block="deadline" in row.from_block)
|
|
450
|
+
return f"{drawn} {muted('then skip')}" if row.deadline is not None and row.on_timeout == "skip" else drawn
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _wait(value: timedelta | None, *, from_block: bool = False) -> str:
|
|
454
|
+
"""Render one configured wait, saying where the block's own default filled it in."""
|
|
455
|
+
if value is None:
|
|
456
|
+
return "-"
|
|
457
|
+
drawn = format_duration(value)
|
|
458
|
+
return f"{drawn} {muted('block')}" if from_block else drawn
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _duration(record: Record, field: str) -> timedelta | None:
|
|
462
|
+
"""Read one of the record's own durations back as the timedelta it was written from."""
|
|
463
|
+
raw = record.get(field)
|
|
464
|
+
return None if raw is None else to_timedelta(raw)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _attempts(record: Record) -> str:
|
|
468
|
+
"""Say how many attempts the document allows, and where that number is only a floor."""
|
|
469
|
+
total = record.get("attempts_max")
|
|
470
|
+
return f"at least {total}" if record.get("attempts_at_least") else f"at most {total}"
|
|
471
|
+
|
|
472
|
+
|
|
398
473
|
def _backfill(record: Record) -> RenderableType | None:
|
|
399
474
|
"""Render a backfill: the windows it enumerated, and the run each one became."""
|
|
400
475
|
windows: list[schemas.BackfillWindow] = _rows(record, "windows", schemas.BackfillWindow)
|
|
@@ -442,6 +517,7 @@ RENDERERS: Final[Mapping[str, Callable[[Record], RenderableType | None]]] = {
|
|
|
442
517
|
"config": _config,
|
|
443
518
|
"db.history": _db_history,
|
|
444
519
|
"validation": _validation,
|
|
520
|
+
"validation.shape": _shape,
|
|
445
521
|
"error": _refusal,
|
|
446
522
|
"token.issued": _issued,
|
|
447
523
|
"process": _issued,
|
|
@@ -5,7 +5,7 @@ from typing import Annotated, Any, cast
|
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
|
-
from dirigent_cli.commands import paged, parse_log_levels, parse_params, parse_priority
|
|
8
|
+
from dirigent_cli.commands import paged, parse_importance, parse_log_levels, parse_params, parse_priority
|
|
9
9
|
from dirigent_cli.context import Session, client_for, state_of
|
|
10
10
|
from dirigent_cli.messages import (
|
|
11
11
|
AT_TAKES_AN_INSTANT,
|
|
@@ -15,6 +15,7 @@ from dirigent_cli.messages import (
|
|
|
15
15
|
ONE_CLOCK,
|
|
16
16
|
)
|
|
17
17
|
from dirigent_cli.output import (
|
|
18
|
+
alert_event,
|
|
18
19
|
console,
|
|
19
20
|
emit_fact,
|
|
20
21
|
emit_one,
|
|
@@ -24,8 +25,10 @@ from dirigent_cli.output import (
|
|
|
24
25
|
render_bool,
|
|
25
26
|
styled,
|
|
26
27
|
table,
|
|
28
|
+
throttle_note,
|
|
29
|
+
watching,
|
|
27
30
|
)
|
|
28
|
-
from dirigent_client import AlertEvent, AlertScope, WebhookTokenOut
|
|
31
|
+
from dirigent_client import LOG_NOTIFIER, AlertEvent, AlertScope, WebhookTokenOut
|
|
29
32
|
from dirigent_common import Message
|
|
30
33
|
|
|
31
34
|
schedule_app = typer.Typer(
|
|
@@ -416,15 +419,15 @@ def alerts_rules_list(
|
|
|
416
419
|
return emit_records("alert_rule", rows)
|
|
417
420
|
table(
|
|
418
421
|
"alert rules",
|
|
419
|
-
["code", "name", "event", "scope", "
|
|
422
|
+
["code", "name", "event", "scope", "target", "throttle", "active", "last sent"],
|
|
420
423
|
[
|
|
421
424
|
[
|
|
422
425
|
row.code,
|
|
423
426
|
row.name or "-",
|
|
424
|
-
row.event.value,
|
|
425
|
-
row.pipeline or row.scope.value,
|
|
426
|
-
row.
|
|
427
|
-
row.throttle,
|
|
427
|
+
alert_event(row.event.value),
|
|
428
|
+
watching(row.pipeline or row.scope.value, row.importance),
|
|
429
|
+
row.connection or LOG_NOTIFIER,
|
|
430
|
+
throttle_note(row.throttle),
|
|
428
431
|
render_bool(row.active),
|
|
429
432
|
moment(row.last_sent_at),
|
|
430
433
|
]
|
|
@@ -444,11 +447,13 @@ def alerts_rules_create(
|
|
|
444
447
|
help="The event that fires it: run_failed, run_completed_with_errors, run_succeeded, or run_stuck.",
|
|
445
448
|
),
|
|
446
449
|
],
|
|
447
|
-
notifier: Annotated[str, typer.Option("--notifier", help="The channel to deliver through, such as log.")],
|
|
448
450
|
name: Annotated[str | None, typer.Option("--name", help="A human title for this rule.")] = None,
|
|
449
451
|
description: Annotated[str | None, typer.Option("--description", help="What this rule is for.")] = None,
|
|
450
452
|
pipeline: Annotated[str | None, typer.Option("--pipeline", help="Watch one pipeline instead of all.")] = None,
|
|
451
|
-
connection: Annotated[
|
|
453
|
+
connection: Annotated[
|
|
454
|
+
str | None,
|
|
455
|
+
typer.Option("--connection", help="The connection to deliver through; none delivers to the process log."),
|
|
456
|
+
] = None,
|
|
452
457
|
template: Annotated[
|
|
453
458
|
str | None, typer.Option("--template", help="Subject, a Jinja template over the run's facts.")
|
|
454
459
|
] = None,
|
|
@@ -457,14 +462,22 @@ def alerts_rules_create(
|
|
|
457
462
|
Path | None, typer.Option("--body-file", help="Read the body template from a file instead.")
|
|
458
463
|
] = None,
|
|
459
464
|
throttle: Annotated[str, typer.Option("--throttle", help="At most one message per window, e.g. 15m.")] = "0s",
|
|
465
|
+
importance: Annotated[
|
|
466
|
+
str | None,
|
|
467
|
+
typer.Option(
|
|
468
|
+
"--importance",
|
|
469
|
+
help="Only pipelines carrying at least this much: routine, normal, or critical.",
|
|
470
|
+
),
|
|
471
|
+
] = None,
|
|
460
472
|
) -> None:
|
|
461
|
-
"""Declare an alert rule binding an event at a scope to
|
|
473
|
+
"""Declare an alert rule binding an event at a scope to one target."""
|
|
462
474
|
if event not in set(AlertEvent):
|
|
463
475
|
_fail(NOT_AN_ALERT_EVENT, event=repr(event), allowed=", ".join(sorted(AlertEvent)))
|
|
464
476
|
if body is not None and body_file is not None:
|
|
465
477
|
_fail(ONE_BODY)
|
|
466
478
|
if body_file is not None:
|
|
467
479
|
body = body_file.read_text()
|
|
480
|
+
floor = parse_importance(importance)
|
|
468
481
|
with client_for(state_of(ctx)) as dg:
|
|
469
482
|
created = dg.call(
|
|
470
483
|
dg.alerts.create_rule(
|
|
@@ -472,9 +485,9 @@ def alerts_rules_create(
|
|
|
472
485
|
name=name,
|
|
473
486
|
description=description,
|
|
474
487
|
event=AlertEvent(event),
|
|
475
|
-
notifier=notifier,
|
|
476
488
|
scope=AlertScope.PIPELINE if pipeline else AlertScope.GLOBAL,
|
|
477
489
|
pipeline=pipeline,
|
|
490
|
+
importance=floor,
|
|
478
491
|
connection=connection,
|
|
479
492
|
template=template,
|
|
480
493
|
body=body,
|
|
@@ -488,6 +501,7 @@ def alerts_rules_create(
|
|
|
488
501
|
name=created.name,
|
|
489
502
|
event=created.event.value,
|
|
490
503
|
scope=created.pipeline or created.scope.value,
|
|
504
|
+
importance=created.importance.value if created.importance else None,
|
|
491
505
|
notifier=created.notifier,
|
|
492
506
|
connection=created.connection,
|
|
493
507
|
template=created.template is not None,
|
|
@@ -532,16 +546,15 @@ def _set_rule_paused(ctx: typer.Context, code: str, *, paused: bool) -> None:
|
|
|
532
546
|
@alerts_app.command("test")
|
|
533
547
|
def alerts_test(
|
|
534
548
|
ctx: typer.Context,
|
|
535
|
-
notifier: Annotated[str, typer.Argument(help="The channel to send through, such as log or webhook.")],
|
|
536
549
|
connection: Annotated[
|
|
537
550
|
str | None,
|
|
538
|
-
typer.Option("--connection", help="The
|
|
551
|
+
typer.Option("--connection", help="The connection to deliver through; none delivers to the process log."),
|
|
539
552
|
] = None,
|
|
540
553
|
subject: Annotated[str, typer.Option("--subject", help="What the test message says.")] = "dirigent test alert",
|
|
541
554
|
) -> None:
|
|
542
|
-
"""Send a test message
|
|
555
|
+
"""Send a test message to one target, on the same queue a real alert takes."""
|
|
543
556
|
with client_for(state_of(ctx)) as dg:
|
|
544
|
-
queued = dg.call(dg.alerts.test(
|
|
557
|
+
queued = dg.call(dg.alerts.test(connection=connection, subject=subject))
|
|
545
558
|
emit_fact(
|
|
546
559
|
"notification.queued",
|
|
547
560
|
message="queued",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/__init__.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/operator.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.18.0 → dirigent_cli-0.18.2}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl
RENAMED
|
File without changes
|
|
File without changes
|