dirigent-cli 0.20.0__tar.gz → 0.22.0__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.20.0 → dirigent_cli-0.22.0}/PKG-INFO +9 -9
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/pyproject.toml +9 -9
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/pyproject.toml.orig +9 -9
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/commands.py +6 -2
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/context.py +8 -1
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/explain.py +31 -11
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/local.py +4 -3
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/main.py +11 -5
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/messages.py +10 -1
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/output.py +39 -9
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/profiles.py +27 -10
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/schemas.py +4 -1
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/seeding.py +2 -1
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/sources.py +4 -1
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/triggers.py +70 -3
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/LICENSE +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/README.md +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/__init__.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/aliases.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/formatters.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/graph.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/health.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/init_form.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/params.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/project.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/py.typed +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/reaper.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/scaffold.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/starters.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/stream.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/summaries.py +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
- {dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/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.
|
|
3
|
+
Version: 0.22.0
|
|
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.
|
|
10
|
-
Requires-Dist: dirigent-blocks==0.
|
|
11
|
-
Requires-Dist: dirigent-client==0.
|
|
12
|
-
Requires-Dist: dirigent-common==0.
|
|
13
|
-
Requires-Dist: dirigent-core==0.
|
|
14
|
-
Requires-Dist: dirigent-examples==0.
|
|
15
|
-
Requires-Dist: dirigent-plugin==0.
|
|
16
|
-
Requires-Dist: dirigent-server==0.
|
|
9
|
+
Requires-Dist: dirigent-block-execute==0.22.0
|
|
10
|
+
Requires-Dist: dirigent-blocks==0.22.0
|
|
11
|
+
Requires-Dist: dirigent-client==0.22.0
|
|
12
|
+
Requires-Dist: dirigent-common==0.22.0
|
|
13
|
+
Requires-Dist: dirigent-core==0.22.0
|
|
14
|
+
Requires-Dist: dirigent-examples==0.22.0
|
|
15
|
+
Requires-Dist: dirigent-plugin==0.22.0
|
|
16
|
+
Requires-Dist: dirigent-server==0.22.0
|
|
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.
|
|
3
|
+
version = "0.22.0"
|
|
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.
|
|
15
|
-
"dirigent-blocks==0.
|
|
16
|
-
"dirigent-client==0.
|
|
17
|
-
"dirigent-common==0.
|
|
18
|
-
"dirigent-core==0.
|
|
19
|
-
"dirigent-examples==0.
|
|
20
|
-
"dirigent-plugin==0.
|
|
21
|
-
"dirigent-server==0.
|
|
14
|
+
"dirigent-block-execute==0.22.0",
|
|
15
|
+
"dirigent-blocks==0.22.0",
|
|
16
|
+
"dirigent-client==0.22.0",
|
|
17
|
+
"dirigent-common==0.22.0",
|
|
18
|
+
"dirigent-core==0.22.0",
|
|
19
|
+
"dirigent-examples==0.22.0",
|
|
20
|
+
"dirigent-plugin==0.22.0",
|
|
21
|
+
"dirigent-server==0.22.0",
|
|
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.
|
|
3
|
+
version = "0.22.0"
|
|
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.
|
|
15
|
-
"dirigent-blocks==0.
|
|
16
|
-
"dirigent-client==0.
|
|
17
|
-
"dirigent-common==0.
|
|
18
|
-
"dirigent-core==0.
|
|
19
|
-
"dirigent-examples==0.
|
|
20
|
-
"dirigent-plugin==0.
|
|
21
|
-
"dirigent-server==0.
|
|
14
|
+
"dirigent-block-execute==0.22.0",
|
|
15
|
+
"dirigent-blocks==0.22.0",
|
|
16
|
+
"dirigent-client==0.22.0",
|
|
17
|
+
"dirigent-common==0.22.0",
|
|
18
|
+
"dirigent-core==0.22.0",
|
|
19
|
+
"dirigent-examples==0.22.0",
|
|
20
|
+
"dirigent-plugin==0.22.0",
|
|
21
|
+
"dirigent-server==0.22.0",
|
|
22
22
|
"httpx2>=2.12.0",
|
|
23
23
|
"python-dotenv>=1.1.0",
|
|
24
24
|
"pyyaml>=6.0.3",
|
|
@@ -86,8 +86,10 @@ from dirigent_cli.messages import (
|
|
|
86
86
|
)
|
|
87
87
|
from dirigent_cli.messages import WINDOW_GRAMMAR as WINDOW_GRAMMAR_MESSAGE
|
|
88
88
|
from dirigent_cli.output import (
|
|
89
|
+
CONNECTION_HEALTH,
|
|
89
90
|
Detail,
|
|
90
91
|
age,
|
|
92
|
+
connection_health,
|
|
91
93
|
console,
|
|
92
94
|
elapsed,
|
|
93
95
|
emit_event,
|
|
@@ -350,7 +352,9 @@ def apply_command(
|
|
|
350
352
|
as_code: Annotated[str | None, typer.Option("--as", help="Register under a different code.")] = None,
|
|
351
353
|
paused: Annotated[
|
|
352
354
|
bool,
|
|
353
|
-
typer.Option(
|
|
355
|
+
typer.Option(
|
|
356
|
+
"--paused", help="Create the schedules and watches this apply mints paused; existing ones are untouched."
|
|
357
|
+
),
|
|
354
358
|
] = False,
|
|
355
359
|
prune: Annotated[
|
|
356
360
|
bool,
|
|
@@ -2433,7 +2437,7 @@ def connection_check(ctx: typer.Context, code: Annotated[str, typer.Argument()])
|
|
|
2433
2437
|
report = dg.call(dg.connections.check(code))
|
|
2434
2438
|
emit_fact(
|
|
2435
2439
|
"connection.checked",
|
|
2436
|
-
message=
|
|
2440
|
+
message=CONNECTION_HEALTH[connection_health(report.healthy)],
|
|
2437
2441
|
code=code,
|
|
2438
2442
|
healthy=report.healthy,
|
|
2439
2443
|
detail=report.detail,
|
|
@@ -143,4 +143,11 @@ class Session(BlockingDirigent):
|
|
|
143
143
|
def client_for(state: CliState, *, needs_token: bool = True) -> Session:
|
|
144
144
|
"""Build the API client for a resolved endpoint."""
|
|
145
145
|
endpoint = state.endpoint(needs_token=needs_token)
|
|
146
|
-
return Session(
|
|
146
|
+
return Session(
|
|
147
|
+
url=endpoint.url,
|
|
148
|
+
token=endpoint.token,
|
|
149
|
+
api_prefix=endpoint.api_prefix,
|
|
150
|
+
timeout=endpoint.timeout.total_seconds(),
|
|
151
|
+
connect_timeout=endpoint.connect_timeout.total_seconds(),
|
|
152
|
+
retries=endpoint.retries,
|
|
153
|
+
)
|
|
@@ -7,9 +7,10 @@ under a deadline. What only the run can decide -- a parameter supplied at the co
|
|
|
7
7
|
a window's bounds -- is reported as unknown rather than guessed at, and a total that counted
|
|
8
8
|
one of those says so.
|
|
9
9
|
|
|
10
|
-
The cardinality rules are
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
The cardinality rules are the engine's: a literal list is its own length,
|
|
11
|
+
``${steps.<name>.items}`` adopts another fan-out's grid, a reference that resolves to a list is
|
|
12
|
+
that list, one that reads a step's output is as wide as that step makes it while the run
|
|
13
|
+
executes, and anything else is only the run's to answer.
|
|
13
14
|
"""
|
|
14
15
|
|
|
15
16
|
from collections.abc import Mapping, Sequence
|
|
@@ -31,6 +32,9 @@ UNKNOWN: Final = "unknown"
|
|
|
31
32
|
#: How a cardinality names the fan-out whose grid a step maps over instead of its own.
|
|
32
33
|
ADOPTS: Final = "adopts {step}"
|
|
33
34
|
|
|
35
|
+
#: How a cardinality names the step whose output a late grid is expanded from.
|
|
36
|
+
LATE: Final = "from {step}"
|
|
37
|
+
|
|
34
38
|
|
|
35
39
|
class DocumentShape(BaseModel):
|
|
36
40
|
"""What a whole document will cost: the steps, the totals over them, and the warnings."""
|
|
@@ -61,7 +65,7 @@ def explain(definition: PipelineDefinition, catalog: Catalog | None = None) -> D
|
|
|
61
65
|
order = definition.topological_order()
|
|
62
66
|
shapes: dict[str, StepShape] = {}
|
|
63
67
|
for name in order:
|
|
64
|
-
shapes[name] = _step_shape(name, definition.steps[name], params, shapes, catalog)
|
|
68
|
+
shapes[name] = _step_shape(name, definition.steps[name], params, shapes, catalog, definition.grid_source(name))
|
|
65
69
|
rows = [shapes[name] for name in order]
|
|
66
70
|
return DocumentShape(
|
|
67
71
|
attempts_max=sum(row.max_attempts * (1 if row.elements is None else row.elements) for row in rows),
|
|
@@ -90,9 +94,13 @@ def _step_shape(
|
|
|
90
94
|
params: JsonMap,
|
|
91
95
|
done: Mapping[str, StepShape],
|
|
92
96
|
catalog: Catalog | None,
|
|
97
|
+
source: str | None,
|
|
93
98
|
) -> StepShape:
|
|
94
|
-
"""Read one step as the work it will become, against the steps already read.
|
|
95
|
-
|
|
99
|
+
"""Read one step as the work it will become, against the steps already read.
|
|
100
|
+
|
|
101
|
+
``source`` is the step a late grid waits for, which the row names.
|
|
102
|
+
"""
|
|
103
|
+
cardinality, elements = _cardinality(step, params, done, source)
|
|
96
104
|
poll, deadline, from_block = _waits(step, _entry(step, catalog))
|
|
97
105
|
return StepShape(
|
|
98
106
|
name=name,
|
|
@@ -100,6 +108,7 @@ def _step_shape(
|
|
|
100
108
|
depends_on=list(step.depends_on),
|
|
101
109
|
cardinality=cardinality,
|
|
102
110
|
elements=elements,
|
|
111
|
+
grid_source=source,
|
|
103
112
|
reference=step.for_each if isinstance(step.for_each, str) else None,
|
|
104
113
|
items=step.items.value,
|
|
105
114
|
max_attempts=step.retry.max_attempts,
|
|
@@ -112,7 +121,9 @@ def _step_shape(
|
|
|
112
121
|
)
|
|
113
122
|
|
|
114
123
|
|
|
115
|
-
def _cardinality(
|
|
124
|
+
def _cardinality(
|
|
125
|
+
step: StepDefinition, params: JsonMap, done: Mapping[str, StepShape], source: str | None
|
|
126
|
+
) -> tuple[int | str, int | None]:
|
|
116
127
|
"""Say how many run items a step becomes, and the count behind that where there is one."""
|
|
117
128
|
expression = step.for_each
|
|
118
129
|
if expression is None:
|
|
@@ -123,10 +134,8 @@ def _cardinality(step: StepDefinition, params: JsonMap, done: Mapping[str, StepS
|
|
|
123
134
|
if adopted is not None:
|
|
124
135
|
upstream = done[adopted].elements if adopted in done else None
|
|
125
136
|
return ADOPTS.format(step=adopted), upstream
|
|
126
|
-
if
|
|
127
|
-
|
|
128
|
-
# run is created and no step has run by then.
|
|
129
|
-
return UNKNOWN, None
|
|
137
|
+
if source is not None:
|
|
138
|
+
return LATE.format(step=source), None
|
|
130
139
|
try:
|
|
131
140
|
resolved = resolve(expression, ReferenceScope(params=params))
|
|
132
141
|
except UnknownReference:
|
|
@@ -202,6 +211,17 @@ def _warnings(rows: Sequence[StepShape], catalog: Catalog | None) -> list[ShapeW
|
|
|
202
211
|
),
|
|
203
212
|
)
|
|
204
213
|
)
|
|
214
|
+
if row.grid_source is not None and row.cardinality == LATE.format(step=row.grid_source):
|
|
215
|
+
found.append(
|
|
216
|
+
ShapeWarning(
|
|
217
|
+
step=row.name,
|
|
218
|
+
cause="late-cardinality",
|
|
219
|
+
message=(
|
|
220
|
+
f"{row.name} fans out over {row.reference}, which {row.grid_source} produces while "
|
|
221
|
+
f"the run executes, so it is counted as one item here."
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
)
|
|
205
225
|
if row.retry_wait is not None and row.deadline is not None and row.retry_wait > row.deadline:
|
|
206
226
|
found.append(
|
|
207
227
|
ShapeWarning(
|
|
@@ -645,7 +645,8 @@ async def run_document(
|
|
|
645
645
|
async with session_scope(sessions) as session:
|
|
646
646
|
await _apply_supporting(session, services, extra_definition)
|
|
647
647
|
async with session_scope(sessions) as session:
|
|
648
|
-
|
|
648
|
+
# Triggers land paused: the instance runs only the run it was asked for.
|
|
649
|
+
applied = await apply_document(session, services, definition, pause_schedules=True)
|
|
649
650
|
if not applied.plan.ok:
|
|
650
651
|
raise LocalError(
|
|
651
652
|
"the document does not validate against the installed catalog:\n"
|
|
@@ -697,8 +698,8 @@ def _parse_supporting(text: str) -> PipelineDefinition:
|
|
|
697
698
|
|
|
698
699
|
|
|
699
700
|
async def _apply_supporting(session: AsyncSession, services: EngineServices, supporting: PipelineDefinition) -> None:
|
|
700
|
-
"""Apply one document a run depends on, without running it."""
|
|
701
|
-
applied = await apply_document(session, services, supporting)
|
|
701
|
+
"""Apply one document a run depends on, without running it or arming its triggers."""
|
|
702
|
+
applied = await apply_document(session, services, supporting, pause_schedules=True)
|
|
702
703
|
if not applied.plan.ok:
|
|
703
704
|
raise LocalError(
|
|
704
705
|
f"the supporting document {supporting.code!r} does not validate:\n"
|
|
@@ -50,7 +50,7 @@ from dirigent_cli.messages import (
|
|
|
50
50
|
)
|
|
51
51
|
from dirigent_cli.output import configure, detail_mode, emit_fact, emit_problem, emit_rendered, refuse
|
|
52
52
|
from dirigent_client.enums import UserRole
|
|
53
|
-
from dirigent_common import Issue
|
|
53
|
+
from dirigent_common import Issue, raised_detail, validation_issues
|
|
54
54
|
from dirigent_core import migrations
|
|
55
55
|
from dirigent_core.config import STATE_DIR, Settings, get_settings, redacted_url, reset_settings_cache
|
|
56
56
|
from dirigent_core.logging import configure_logging, silence_stdout
|
|
@@ -127,6 +127,7 @@ app.add_typer(commands.schema_app, rich_help_panel=DEFINE_PANEL)
|
|
|
127
127
|
app.add_typer(commands.connection_app, rich_help_panel=CONNECT_PANEL)
|
|
128
128
|
app.add_typer(triggers.schedule_app, rich_help_panel=TRIGGER_PANEL)
|
|
129
129
|
app.add_typer(triggers.webhook_app, rich_help_panel=TRIGGER_PANEL)
|
|
130
|
+
app.add_typer(triggers.watch_app, rich_help_panel=TRIGGER_PANEL)
|
|
130
131
|
app.add_typer(triggers.trigger_document_app, rich_help_panel=TRIGGER_PANEL)
|
|
131
132
|
app.add_typer(triggers.alerts_app, rich_help_panel=TRIGGER_PANEL)
|
|
132
133
|
app.add_typer(docker_app, rich_help_panel=PROCESS_PANEL)
|
|
@@ -401,7 +402,7 @@ async def _ensure_container(settings: Settings) -> "ContainerResult":
|
|
|
401
402
|
except DomainError as error:
|
|
402
403
|
commands.fail(STORE_UNCONFIGURED, root=settings.artifact_root, detail=str(error))
|
|
403
404
|
except Exception as error:
|
|
404
|
-
commands.fail(STORE_UNREACHABLE, root=settings.artifact_root, detail=
|
|
405
|
+
commands.fail(STORE_UNREACHABLE, root=settings.artifact_root, detail=raised_detail(error))
|
|
405
406
|
finally:
|
|
406
407
|
await engine.dispose()
|
|
407
408
|
|
|
@@ -442,7 +443,12 @@ def connection_ensure(
|
|
|
442
443
|
except ValidationError as error:
|
|
443
444
|
# include_input=False: the input here is a credential, and pydantic's default error
|
|
444
445
|
# payload echoes the value that failed.
|
|
445
|
-
commands.fail(
|
|
446
|
+
commands.fail(
|
|
447
|
+
CONNECTION_UNUSABLE,
|
|
448
|
+
code=code,
|
|
449
|
+
kind=kind_id,
|
|
450
|
+
problems=validation_issues(error.errors(include_input=False)),
|
|
451
|
+
)
|
|
446
452
|
key = settings.secret_key.get_secret_value() if settings.secret_key else None
|
|
447
453
|
try:
|
|
448
454
|
public, envelope, key_id = SecretBox(key).encrypt_config(model, validated)
|
|
@@ -911,7 +917,7 @@ def dev(
|
|
|
911
917
|
typer.Option(
|
|
912
918
|
"--seed",
|
|
913
919
|
help="Apply every dirigent/v1 document under this directory once the API answers, "
|
|
914
|
-
"with its schedules paused; name it more than once to seed one directory after another.",
|
|
920
|
+
"with its schedules and watches paused; name it more than once to seed one directory after another.",
|
|
915
921
|
),
|
|
916
922
|
] = None,
|
|
917
923
|
seed_installed: Annotated[
|
|
@@ -934,7 +940,7 @@ def dev(
|
|
|
934
940
|
|
|
935
941
|
--seed fills the instance from a directory of documents the moment it answers: the
|
|
936
942
|
connections a file or a document declares are created first, then every document is
|
|
937
|
-
applied with its schedules paused. A document an instance will not store is reported
|
|
943
|
+
applied with its schedules and watches paused. A document an instance will not store is reported
|
|
938
944
|
and passed over, because a corpus holds those on purpose.
|
|
939
945
|
"""
|
|
940
946
|
import asyncio
|
|
@@ -25,7 +25,7 @@ RUN_DB_UPGRADE = CLI.define("run_db_upgrade", "run dg db upgrade")
|
|
|
25
25
|
|
|
26
26
|
UNKNOWN_CONNECTION_KIND = CLI.define("unknown_connection_kind", "no connection kind {kind} is installed ({known})")
|
|
27
27
|
|
|
28
|
-
CONNECTION_UNUSABLE = CLI.define("connection_unusable", "{code} is not a usable {kind} connection
|
|
28
|
+
CONNECTION_UNUSABLE = CLI.define("connection_unusable", "{code} is not a usable {kind} connection")
|
|
29
29
|
|
|
30
30
|
INVALID_AGE = CLI.define("invalid_age", "{detail}")
|
|
31
31
|
|
|
@@ -189,6 +189,15 @@ SCHEMA_NOT_AN_OBJECT = CLI.define(
|
|
|
189
189
|
"{label} is not a JSON Schema: a schema is an object, and this is {kind}",
|
|
190
190
|
)
|
|
191
191
|
|
|
192
|
+
# What a profile refuses at validation. Pydantic owns the code a validator's refusal reaches
|
|
193
|
+
# the wire under, so this is rendered into the ``ValueError`` it wraps.
|
|
194
|
+
|
|
195
|
+
PROFILE_NAMES_A_DATABASE = CLI.define(
|
|
196
|
+
"profile_names_a_database",
|
|
197
|
+
"profile {name} names a database URL. Profiles address a server over HTTP; "
|
|
198
|
+
"a database URL belongs in DIRIGENT_DATABASE_URL on the host that runs it",
|
|
199
|
+
)
|
|
200
|
+
|
|
192
201
|
NOT_AUTHENTICATED = CLI.define("not_authenticated", "no token for {url}")
|
|
193
202
|
|
|
194
203
|
SET_DG_TOKEN = CLI.define("set_dg_token", "set DG_TOKEN")
|
|
@@ -474,16 +474,24 @@ def prioritised(priority: object) -> str:
|
|
|
474
474
|
return ""
|
|
475
475
|
|
|
476
476
|
|
|
477
|
+
#: The floor an alert rule fires at, said beside its scope. The web UI writes this half in the
|
|
478
|
+
#: same words out of its own catalogue, and `tests/test_shared_words.py` holds the two together.
|
|
479
|
+
IMPORTANCE_FLOOR = "{importance} and above"
|
|
480
|
+
|
|
481
|
+
|
|
477
482
|
def watching(scope: object, importance: object) -> str:
|
|
478
483
|
"""What an alert rule watches, in one cell: its scope, and the floor it fires at.
|
|
479
484
|
|
|
480
485
|
Almost every rule names no importance, so a column of its own would mostly be empty.
|
|
481
486
|
"""
|
|
482
|
-
|
|
487
|
+
if not importance:
|
|
488
|
+
return str(scope)
|
|
489
|
+
return f"{scope}, {IMPORTANCE_FLOOR.format(importance=importance)}"
|
|
483
490
|
|
|
484
491
|
|
|
485
492
|
#: 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
|
|
493
|
+
#: with; these are the words a reader is shown. The web UI draws the same four out of its own
|
|
494
|
+
#: catalogue, and `tests/test_shared_words.py` fails when the two tables stop agreeing.
|
|
487
495
|
ALERT_EVENTS: Mapping[str, str] = {
|
|
488
496
|
"run_failed": "Failed",
|
|
489
497
|
"run_completed_with_errors": "Completed with errors",
|
|
@@ -529,17 +537,39 @@ def render_bool(value: object) -> str:
|
|
|
529
537
|
return "[green]yes[/]" if value else "[dim]no[/]"
|
|
530
538
|
|
|
531
539
|
|
|
540
|
+
#: What a connection's last check is called, keyed by the state the web UI keys it by. A
|
|
541
|
+
#: credential's health is one vocabulary across the product, so these are the bundle's own four
|
|
542
|
+
#: words and `tests/test_shared_words.py` fails when the two tables stop agreeing.
|
|
543
|
+
CONNECTION_HEALTH: Mapping[str, str] = {
|
|
544
|
+
"unchecked": "never checked",
|
|
545
|
+
"unverified": "not verified",
|
|
546
|
+
"healthy": "healthy",
|
|
547
|
+
"failed": "failed",
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def connection_health(healthy: object) -> str:
|
|
552
|
+
"""Say which state a check that ran left a connection in.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
healthy: What the check decided, or None where the kind publishes no check.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
The key into `CONNECTION_HEALTH`.
|
|
559
|
+
"""
|
|
560
|
+
if healthy is None:
|
|
561
|
+
return "unverified"
|
|
562
|
+
return "healthy" if healthy else "failed"
|
|
563
|
+
|
|
564
|
+
|
|
532
565
|
def render_check(last_check_at: object, healthy: object) -> str:
|
|
533
566
|
"""Render what a connection's last check said, across the four states a row can be in.
|
|
534
567
|
|
|
535
|
-
A check answers
|
|
536
|
-
none of the three.
|
|
568
|
+
A check answers that the system is there, that it is not, or that the kind publishes no
|
|
569
|
+
check at all; a row nothing has ever asked is in none of the three.
|
|
537
570
|
"""
|
|
538
|
-
if
|
|
539
|
-
|
|
540
|
-
if healthy is None:
|
|
541
|
-
return "[dim]not verified[/]"
|
|
542
|
-
return "[green]yes[/]" if healthy else "[dim]no[/]"
|
|
571
|
+
state = connection_health(healthy) if last_check_at else "unchecked"
|
|
572
|
+
return f"[{STATUS_STYLES.get(state, 'dim')}]{CONNECTION_HEALTH[state]}[/]"
|
|
543
573
|
|
|
544
574
|
|
|
545
575
|
def moment(value: object) -> str:
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
"""Profiles: which server the CLI talks to,
|
|
1
|
+
"""Profiles: which server the CLI talks to, how it gets a token for it, and how patient it is.
|
|
2
2
|
|
|
3
|
-
A profile is client-side
|
|
4
|
-
|
|
3
|
+
A profile is client-side only and must never hold a database URL: a CLI that could reach the
|
|
4
|
+
database would bypass authentication, attribution, and validation.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
8
|
import shutil
|
|
9
9
|
import subprocess
|
|
10
10
|
from collections.abc import Mapping
|
|
11
|
+
from datetime import timedelta
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Final, cast
|
|
13
14
|
|
|
@@ -15,8 +16,9 @@ import yaml
|
|
|
15
16
|
from dotenv import dotenv_values
|
|
16
17
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
|
17
18
|
|
|
18
|
-
from
|
|
19
|
-
from
|
|
19
|
+
from dirigent_cli.messages import PROFILE_NAMES_A_DATABASE
|
|
20
|
+
from dirigent_client import API_PREFIX, DEFAULT_CONNECT_TIMEOUT, DEFAULT_RETRIES, DEFAULT_TIMEOUT
|
|
21
|
+
from dirigent_common import Duration, EntityName
|
|
20
22
|
|
|
21
23
|
PROJECT_PROFILES: Final = Path(".dirigent") / "profiles.yaml"
|
|
22
24
|
PROJECT_ENV_FILE: Final = ".env"
|
|
@@ -32,13 +34,16 @@ DATABASE_SCHEMES: Final = ("postgresql", "postgres", "sqlite", "mysql")
|
|
|
32
34
|
|
|
33
35
|
TOKEN_COMMAND_TIMEOUT: Final = 30.0
|
|
34
36
|
|
|
37
|
+
DEFAULT_REQUEST_TIMEOUT: Final = timedelta(seconds=DEFAULT_TIMEOUT)
|
|
38
|
+
DEFAULT_CONNECT_BUDGET: Final = timedelta(seconds=DEFAULT_CONNECT_TIMEOUT)
|
|
39
|
+
|
|
35
40
|
|
|
36
41
|
class ProfileError(Exception):
|
|
37
42
|
"""A profile could not be read, found, or turned into a usable token."""
|
|
38
43
|
|
|
39
44
|
|
|
40
45
|
class Profile(BaseModel):
|
|
41
|
-
"""One named server
|
|
46
|
+
"""One named server, how to obtain a token for it, and how long to wait on it."""
|
|
42
47
|
|
|
43
48
|
model_config = ConfigDict(frozen=True)
|
|
44
49
|
|
|
@@ -53,15 +58,21 @@ class Profile(BaseModel):
|
|
|
53
58
|
api_prefix: str = API_PREFIX
|
|
54
59
|
"""Where this instance serves its API, for one configured with a different prefix."""
|
|
55
60
|
|
|
61
|
+
timeout: Duration = DEFAULT_REQUEST_TIMEOUT
|
|
62
|
+
"""How long a request to this instance may take to answer."""
|
|
63
|
+
|
|
64
|
+
connect_timeout: Duration = DEFAULT_CONNECT_BUDGET
|
|
65
|
+
"""How long reaching this instance may take before it is called unreachable."""
|
|
66
|
+
|
|
67
|
+
retries: int = Field(default=DEFAULT_RETRIES, ge=0)
|
|
68
|
+
"""How many further attempts a lost or refused-with-a-5xx request gets."""
|
|
69
|
+
|
|
56
70
|
@model_validator(mode="after")
|
|
57
71
|
def _refuse_a_database_url(self) -> "Profile":
|
|
58
72
|
"""Refuse a profile pointing at a database rather than at an API."""
|
|
59
73
|
scheme = self.url.split("://", 1)[0].split("+", 1)[0].lower()
|
|
60
74
|
if scheme in DATABASE_SCHEMES:
|
|
61
|
-
raise ValueError(
|
|
62
|
-
f"profile {self.name!r} names a database URL. Profiles address a server over HTTP; "
|
|
63
|
-
f"a database URL belongs in DIRIGENT_DATABASE_URL on the host that runs it"
|
|
64
|
-
)
|
|
75
|
+
raise ValueError(PROFILE_NAMES_A_DATABASE.render(name=repr(self.name)))
|
|
65
76
|
return self
|
|
66
77
|
|
|
67
78
|
def resolve_token(self, environ: Mapping[str, str] | None = None) -> str | None:
|
|
@@ -193,6 +204,9 @@ class Endpoint(BaseModel):
|
|
|
193
204
|
profile: str | None = None
|
|
194
205
|
source: str = "default"
|
|
195
206
|
api_prefix: str = API_PREFIX
|
|
207
|
+
timeout: Duration = DEFAULT_REQUEST_TIMEOUT
|
|
208
|
+
connect_timeout: Duration = DEFAULT_CONNECT_BUDGET
|
|
209
|
+
retries: int = DEFAULT_RETRIES
|
|
196
210
|
|
|
197
211
|
|
|
198
212
|
def resolve_endpoint(
|
|
@@ -236,4 +250,7 @@ def resolve_endpoint(
|
|
|
236
250
|
profile=chosen.name if chosen else None,
|
|
237
251
|
source=source,
|
|
238
252
|
api_prefix=chosen.api_prefix if chosen else API_PREFIX,
|
|
253
|
+
timeout=chosen.timeout if chosen else DEFAULT_REQUEST_TIMEOUT,
|
|
254
|
+
connect_timeout=chosen.connect_timeout if chosen else DEFAULT_CONNECT_BUDGET,
|
|
255
|
+
retries=chosen.retries if chosen else DEFAULT_RETRIES,
|
|
239
256
|
)
|
|
@@ -65,11 +65,14 @@ class StepShape(BaseModel):
|
|
|
65
65
|
depends_on: list[str] = Field(default_factory=list[str])
|
|
66
66
|
|
|
67
67
|
cardinality: int | str = 1
|
|
68
|
-
"""How many run items this step becomes: a count, ``adopts <step>``, or ``unknown``."""
|
|
68
|
+
"""How many run items this step becomes: a count, ``adopts <step>``, ``from <step>``, or ``unknown``."""
|
|
69
69
|
|
|
70
70
|
elements: int | None = 1
|
|
71
71
|
"""The count behind the cardinality, which an adoption takes from the grid it adopts."""
|
|
72
72
|
|
|
73
|
+
grid_source: str | None = None
|
|
74
|
+
"""The step a late grid waits for before it expands, or null for a grid fixed at creation."""
|
|
75
|
+
|
|
73
76
|
reference: str | None = None
|
|
74
77
|
"""The ``for_each`` this step maps over, where it names one rather than listing it."""
|
|
75
78
|
|
|
@@ -136,7 +136,7 @@ async def _connection(client: Dirigent, spec: ConnectionSpec) -> str:
|
|
|
136
136
|
|
|
137
137
|
|
|
138
138
|
async def _apply(client: Dirigent, raw: JsonMap, origin: str) -> Record:
|
|
139
|
-
"""Apply one document with its schedules paused, without the sections it carries."""
|
|
139
|
+
"""Apply one document with its schedules and watches paused, without the sections it carries."""
|
|
140
140
|
document = {name: value for name, value in raw.items() if name not in CARRIED}
|
|
141
141
|
try:
|
|
142
142
|
result = await client.pipelines.apply(
|
|
@@ -157,6 +157,7 @@ async def _apply(client: Dirigent, raw: JsonMap, origin: str) -> Record:
|
|
|
157
157
|
pipeline=result.plan.code,
|
|
158
158
|
action=result.plan.action.value,
|
|
159
159
|
schedules_paused=result.triggers.schedules_created,
|
|
160
|
+
watches_paused=result.triggers.watches_created,
|
|
160
161
|
)
|
|
161
162
|
|
|
162
163
|
|
|
@@ -8,10 +8,13 @@ import httpx2
|
|
|
8
8
|
import yaml
|
|
9
9
|
from pydantic import BaseModel, ConfigDict
|
|
10
10
|
|
|
11
|
+
from dirigent_client import DEFAULT_CONNECT_TIMEOUT
|
|
11
12
|
from dirigent_client.enums import ProvenanceSource
|
|
12
13
|
|
|
13
14
|
STDIN: Final = "-"
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
#: The body may take thirty seconds to arrive, and reaching the host that serves it two.
|
|
17
|
+
FETCH_TIMEOUT: Final = httpx2.Timeout(30.0, connect=DEFAULT_CONNECT_TIMEOUT)
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class SourceError(Exception):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The trigger and alerting command groups: schedules, webhooks, and alert rules."""
|
|
1
|
+
"""The trigger and alerting command groups: schedules, webhooks, watches, and alert rules."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Annotated, Any, cast
|
|
@@ -35,6 +35,11 @@ schedule_app = typer.Typer(
|
|
|
35
35
|
name="schedule", help="A pipeline's clocks: cron, interval, or one-time.", no_args_is_help=True
|
|
36
36
|
)
|
|
37
37
|
webhook_app = typer.Typer(name="webhook", help="Inbound webhooks and their delivery history.", no_args_is_help=True)
|
|
38
|
+
watch_app = typer.Typer(
|
|
39
|
+
name="watch",
|
|
40
|
+
help="Sensors kept waiting: one run always waiting on each, the next armed on success.",
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
)
|
|
38
43
|
trigger_document_app = typer.Typer(
|
|
39
44
|
name="trigger-document",
|
|
40
45
|
help="Documents that declare clocks for a pipeline defined elsewhere.",
|
|
@@ -246,6 +251,60 @@ def schedule_delete(
|
|
|
246
251
|
emit_fact("schedule.deleted", message="deleted", code=code, pipeline=pipeline)
|
|
247
252
|
|
|
248
253
|
|
|
254
|
+
@watch_app.command("list")
|
|
255
|
+
def watch_list(
|
|
256
|
+
ctx: typer.Context,
|
|
257
|
+
pipeline: Annotated[str, typer.Argument(help="The pipeline whose watches to list.")],
|
|
258
|
+
) -> None:
|
|
259
|
+
"""List a pipeline's watches, each with the run it has waiting and how its waits are going."""
|
|
260
|
+
with client_for(state_of(ctx)) as dg:
|
|
261
|
+
rows = list(paged(lambda after, size: dg.call(dg.watches.list(pipeline, after=after, limit=size)), None))
|
|
262
|
+
emit_records("watch", rows)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@watch_app.command("show")
|
|
266
|
+
def watch_show(
|
|
267
|
+
ctx: typer.Context,
|
|
268
|
+
pipeline: Annotated[str, typer.Argument(help="The pipeline the watch belongs to.")],
|
|
269
|
+
code: Annotated[str, typer.Argument(help="The watch to show.")],
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Show one watch: the step it waits on, where it left off, and its last error."""
|
|
272
|
+
with client_for(state_of(ctx)) as dg:
|
|
273
|
+
row = dg.call(dg.watches.get(pipeline, code))
|
|
274
|
+
emit_one("watch", row)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@watch_app.command("pause")
|
|
278
|
+
def watch_pause(
|
|
279
|
+
ctx: typer.Context,
|
|
280
|
+
pipeline: Annotated[str, typer.Argument()],
|
|
281
|
+
code: Annotated[str, typer.Argument()],
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Stop a watch, cancelling the run it has waiting, and keep where it left off."""
|
|
284
|
+
with client_for(state_of(ctx)) as dg:
|
|
285
|
+
row = dg.call(dg.watches.pause(pipeline, code))
|
|
286
|
+
emit_fact("watch.paused", message="paused", code=code, pipeline=pipeline, cursor=row.cursor)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@watch_app.command("resume")
|
|
290
|
+
def watch_resume(
|
|
291
|
+
ctx: typer.Context,
|
|
292
|
+
pipeline: Annotated[str, typer.Argument()],
|
|
293
|
+
code: Annotated[str, typer.Argument()],
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Start a watch again, arming a run from where its last success left off."""
|
|
296
|
+
with client_for(state_of(ctx)) as dg:
|
|
297
|
+
row = dg.call(dg.watches.resume(pipeline, code))
|
|
298
|
+
emit_fact(
|
|
299
|
+
"watch.resumed",
|
|
300
|
+
message="resumed",
|
|
301
|
+
code=code,
|
|
302
|
+
pipeline=pipeline,
|
|
303
|
+
waiting_run_id=row.waiting_run_id,
|
|
304
|
+
cursor=row.cursor,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
249
308
|
def _print_token(minted: WebhookTokenOut, *, base_url: str) -> None:
|
|
250
309
|
"""Print a minted token once, with the URL already assembled.
|
|
251
310
|
|
|
@@ -621,7 +680,7 @@ def trigger_document_show(
|
|
|
621
680
|
ctx: typer.Context,
|
|
622
681
|
code: Annotated[str, typer.Argument(help="The triggers document to read.")],
|
|
623
682
|
) -> None:
|
|
624
|
-
"""Show one triggers document and the schedules and
|
|
683
|
+
"""Show one triggers document and the schedules, webhooks and watches it owns."""
|
|
625
684
|
with client_for(state_of(ctx)) as dg:
|
|
626
685
|
detail = dg.call(dg.trigger_documents.get(code))
|
|
627
686
|
if state_of(ctx).json_output:
|
|
@@ -631,6 +690,7 @@ def trigger_document_show(
|
|
|
631
690
|
console.print(f" digest [dim]{detail.digest}[/]")
|
|
632
691
|
console.print(f" schedules {', '.join(detail.schedules) or '-'}")
|
|
633
692
|
console.print(f" webhooks {', '.join(detail.webhooks) or '-'}")
|
|
693
|
+
console.print(f" watches {', '.join(detail.watches) or '-'}")
|
|
634
694
|
if detail.description:
|
|
635
695
|
console.print(f"\n{detail.description}")
|
|
636
696
|
|
|
@@ -643,7 +703,14 @@ def trigger_document_delete(
|
|
|
643
703
|
"""Remove a triggers document and every schedule and webhook it declared."""
|
|
644
704
|
with client_for(state_of(ctx)) as dg:
|
|
645
705
|
dg.call(dg.trigger_documents.delete(code))
|
|
646
|
-
emit_fact(
|
|
706
|
+
emit_fact(
|
|
707
|
+
"trigger_document.deleted",
|
|
708
|
+
message="deleted",
|
|
709
|
+
code=code,
|
|
710
|
+
schedules="deleted",
|
|
711
|
+
webhooks="deleted",
|
|
712
|
+
watches="deleted",
|
|
713
|
+
)
|
|
647
714
|
|
|
648
715
|
|
|
649
716
|
@alerts_app.command("retry")
|
|
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.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/operator.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.20.0 → dirigent_cli-0.22.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl
RENAMED
|
File without changes
|
|
File without changes
|