dirigent-cli 0.23.4__tar.gz → 0.24.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.23.4 → dirigent_cli-0.24.0}/PKG-INFO +9 -9
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/pyproject.toml +9 -9
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/pyproject.toml.orig +9 -9
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/commands.py +64 -2
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/explain.py +16 -4
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/graph.py +11 -1
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/local.py +52 -6
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/main.py +11 -5
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/messages.py +15 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/schemas.py +4 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/seeding.py +117 -62
- dirigent_cli-0.24.0/src/dirigent_cli/source_commands.py +203 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/summaries.py +18 -1
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/LICENSE +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/README.md +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/__init__.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/aliases.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/context.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/formatters.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/health.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/init_form.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/output.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/params.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/profiles.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/project.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/py.typed +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/reaper.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/scaffold.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/sources.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/starters.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/stream.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/timing.py +0 -0
- {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/triggers.py +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.24.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.24.0
|
|
10
|
+
Requires-Dist: dirigent-blocks==0.24.0
|
|
11
|
+
Requires-Dist: dirigent-client==0.24.0
|
|
12
|
+
Requires-Dist: dirigent-common==0.24.0
|
|
13
|
+
Requires-Dist: dirigent-core==0.24.0
|
|
14
|
+
Requires-Dist: dirigent-examples==0.24.0
|
|
15
|
+
Requires-Dist: dirigent-plugin==0.24.0
|
|
16
|
+
Requires-Dist: dirigent-server==0.24.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.24.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.24.0",
|
|
15
|
+
"dirigent-blocks==0.24.0",
|
|
16
|
+
"dirigent-client==0.24.0",
|
|
17
|
+
"dirigent-common==0.24.0",
|
|
18
|
+
"dirigent-core==0.24.0",
|
|
19
|
+
"dirigent-examples==0.24.0",
|
|
20
|
+
"dirigent-plugin==0.24.0",
|
|
21
|
+
"dirigent-server==0.24.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.24.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.24.0",
|
|
15
|
+
"dirigent-blocks==0.24.0",
|
|
16
|
+
"dirigent-client==0.24.0",
|
|
17
|
+
"dirigent-common==0.24.0",
|
|
18
|
+
"dirigent-core==0.24.0",
|
|
19
|
+
"dirigent-examples==0.24.0",
|
|
20
|
+
"dirigent-plugin==0.24.0",
|
|
21
|
+
"dirigent-server==0.24.0",
|
|
22
22
|
"httpx2>=2.12.0",
|
|
23
23
|
"python-dotenv>=1.1.0",
|
|
24
24
|
"pyyaml>=6.0.3",
|
|
@@ -43,6 +43,8 @@ from dirigent_cli.messages import (
|
|
|
43
43
|
INIT_CANCELLED,
|
|
44
44
|
INIT_REFUSED,
|
|
45
45
|
INIT_WAY,
|
|
46
|
+
INPUT_FILE_UNREADABLE,
|
|
47
|
+
INPUT_REFUSED,
|
|
46
48
|
INSTANCE_ALREADY_HERE,
|
|
47
49
|
KEEP_IS_LOCAL_ONLY,
|
|
48
50
|
LOCAL_RUN_HAD_NO_OUTCOME,
|
|
@@ -309,6 +311,18 @@ def _print_plan(result: ApplyResult, origin: str) -> bool:
|
|
|
309
311
|
return True
|
|
310
312
|
|
|
311
313
|
|
|
314
|
+
def _print_source_tokens(result: ApplyResult, *, base_url: str) -> None:
|
|
315
|
+
"""Print each token an apply minted for a source, once, with where it is presented.
|
|
316
|
+
|
|
317
|
+
The instance stores only a token's hash; there is no second chance to read it.
|
|
318
|
+
"""
|
|
319
|
+
for minted in result.sources.tokens:
|
|
320
|
+
console.print(f"\n source [bold]{minted.code}[/] {base_url.rstrip('/')}{minted.url_path}")
|
|
321
|
+
console.print(f" Authorization: Bearer [bold]{minted.token}[/]")
|
|
322
|
+
if result.sources.tokens:
|
|
323
|
+
console.print("\n[yellow]Each token is shown once.[/] The instance stores only its hash.\n")
|
|
324
|
+
|
|
325
|
+
|
|
312
326
|
def _print_graph(steps: Sequence[GraphStep], title: str = "steps") -> None:
|
|
313
327
|
"""Print a pipeline's shape: roots at the margin, each step under what it waits for."""
|
|
314
328
|
lines = render_graph(steps)
|
|
@@ -322,7 +336,7 @@ def _print_graph(steps: Sequence[GraphStep], title: str = "steps") -> None:
|
|
|
322
336
|
def graph_steps(definition: PipelineDefinition) -> list[GraphStep]:
|
|
323
337
|
"""Read a definition's steps in the shape the tree is drawn from."""
|
|
324
338
|
return [
|
|
325
|
-
GraphStep(name=name, block=step.
|
|
339
|
+
GraphStep(name=name, block=step.block_id, depends_on=tuple(step.depends_on), rule=step.rule.value)
|
|
326
340
|
for name, step in definition.steps.items()
|
|
327
341
|
]
|
|
328
342
|
|
|
@@ -402,6 +416,8 @@ def apply_command(
|
|
|
402
416
|
raise typer.Exit(code=1)
|
|
403
417
|
return
|
|
404
418
|
accepted = all(_print_plan(result, origin) for result, origin in results)
|
|
419
|
+
for result, _ in results:
|
|
420
|
+
_print_source_tokens(result, base_url=dg.url)
|
|
405
421
|
if pruned is not None:
|
|
406
422
|
for code in pruned.pruned:
|
|
407
423
|
console.print(f"{styled('deactivated'):<10} {code} [dim](absent from the project)[/]")
|
|
@@ -1080,6 +1096,23 @@ def parse_priority(value: str | None) -> RunPriority | None:
|
|
|
1080
1096
|
)
|
|
1081
1097
|
|
|
1082
1098
|
|
|
1099
|
+
def parse_input(value: str | None) -> Any:
|
|
1100
|
+
"""Read what a hand run hands its source: JSON or YAML written out, or ``@FILE`` holding either."""
|
|
1101
|
+
if value is None:
|
|
1102
|
+
return None
|
|
1103
|
+
text = value
|
|
1104
|
+
if value.startswith("@"):
|
|
1105
|
+
path = Path(value[1:])
|
|
1106
|
+
try:
|
|
1107
|
+
text = path.read_text()
|
|
1108
|
+
except OSError as error:
|
|
1109
|
+
fail(INPUT_FILE_UNREADABLE, path=str(path), detail=error.strerror or str(error))
|
|
1110
|
+
try:
|
|
1111
|
+
return yaml.safe_load(text)
|
|
1112
|
+
except yaml.YAMLError as error:
|
|
1113
|
+
fail(INPUT_REFUSED, detail=str(error).splitlines()[0])
|
|
1114
|
+
|
|
1115
|
+
|
|
1083
1116
|
def parse_importance(value: str | None) -> Importance | None:
|
|
1084
1117
|
"""Read the importance a rule was given, naming the vocabulary when it is not one."""
|
|
1085
1118
|
if value is None:
|
|
@@ -1164,6 +1197,20 @@ def run_command(
|
|
|
1164
1197
|
"Omitted takes the pipeline's own.",
|
|
1165
1198
|
),
|
|
1166
1199
|
] = None,
|
|
1200
|
+
source: Annotated[
|
|
1201
|
+
str | None,
|
|
1202
|
+
typer.Option(
|
|
1203
|
+
"--source",
|
|
1204
|
+
help="The source step a pipeline with sources is entered at. Omitted takes its only one.",
|
|
1205
|
+
),
|
|
1206
|
+
] = None,
|
|
1207
|
+
given: Annotated[
|
|
1208
|
+
str | None,
|
|
1209
|
+
typer.Option(
|
|
1210
|
+
"--input",
|
|
1211
|
+
help="What arrives at that source, as JSON or YAML, or @FILE. Omitted takes the source's sample.",
|
|
1212
|
+
),
|
|
1213
|
+
] = None,
|
|
1167
1214
|
) -> None:
|
|
1168
1215
|
"""Run a pipeline by code, or apply and run a document in one command.
|
|
1169
1216
|
|
|
@@ -1182,6 +1229,7 @@ def run_command(
|
|
|
1182
1229
|
wanted = parse_priority(priority)
|
|
1183
1230
|
if wanted is not None and local:
|
|
1184
1231
|
fail(PRIORITY_IS_NOT_LOCAL)
|
|
1232
|
+
arrives = parse_input(given)
|
|
1185
1233
|
if local:
|
|
1186
1234
|
_run_locally(
|
|
1187
1235
|
state,
|
|
@@ -1197,6 +1245,8 @@ def run_command(
|
|
|
1197
1245
|
strict=strict,
|
|
1198
1246
|
keep=keep,
|
|
1199
1247
|
root=root,
|
|
1248
|
+
source=source,
|
|
1249
|
+
given=arrives,
|
|
1200
1250
|
)
|
|
1201
1251
|
return
|
|
1202
1252
|
with client_for(state) as dg:
|
|
@@ -1224,7 +1274,11 @@ def run_command(
|
|
|
1224
1274
|
stored = dg.call(dg.pipelines.get(code))
|
|
1225
1275
|
schema = dict((stored.document or {}).get("params") or {})
|
|
1226
1276
|
params = parse_params(param, schema=schema, files=params_file)
|
|
1227
|
-
accepted = dg.call(
|
|
1277
|
+
accepted = dg.call(
|
|
1278
|
+
dg.pipelines.run(
|
|
1279
|
+
code, params=params, window=covered, log_levels=levels, priority=wanted, source=source, input=arrives
|
|
1280
|
+
)
|
|
1281
|
+
)
|
|
1228
1282
|
if accepted.run_id is None:
|
|
1229
1283
|
if state.json_output:
|
|
1230
1284
|
emit_problem(
|
|
@@ -1315,6 +1369,8 @@ def _run_locally(
|
|
|
1315
1369
|
strict: bool,
|
|
1316
1370
|
keep: bool = False,
|
|
1317
1371
|
root: Path | None = None,
|
|
1372
|
+
source: str | None = None,
|
|
1373
|
+
given: Any = None,
|
|
1318
1374
|
) -> None:
|
|
1319
1375
|
"""Apply and run a document in a throwaway instance, streaming what it logs."""
|
|
1320
1376
|
if state.url is not None or state.profile is not None or state.token is not None:
|
|
@@ -1344,6 +1400,8 @@ def _run_locally(
|
|
|
1344
1400
|
state=state,
|
|
1345
1401
|
keep=keep,
|
|
1346
1402
|
root=root,
|
|
1403
|
+
source=source,
|
|
1404
|
+
given=given,
|
|
1347
1405
|
)
|
|
1348
1406
|
)
|
|
1349
1407
|
if outcome is None: # pragma: no cover - the generator always ends with an outcome
|
|
@@ -1384,6 +1442,8 @@ async def _stream_local(
|
|
|
1384
1442
|
state: CliState,
|
|
1385
1443
|
keep: bool = False,
|
|
1386
1444
|
root: Path | None = None,
|
|
1445
|
+
source: str | None = None,
|
|
1446
|
+
given: Any = None,
|
|
1387
1447
|
) -> LocalOutcome | None:
|
|
1388
1448
|
"""Drive a local run, printing each transition and log line as it lands."""
|
|
1389
1449
|
outcome: LocalOutcome | None = None
|
|
@@ -1400,6 +1460,8 @@ async def _stream_local(
|
|
|
1400
1460
|
inherited=settings,
|
|
1401
1461
|
keep=keep,
|
|
1402
1462
|
root=root,
|
|
1463
|
+
source=source,
|
|
1464
|
+
given=given,
|
|
1403
1465
|
):
|
|
1404
1466
|
match item:
|
|
1405
1467
|
case LocalStarted():
|
|
@@ -25,6 +25,7 @@ from dirigent_common import Duration, JsonMap, base_format_checker, format_durat
|
|
|
25
25
|
from dirigent_core.engine.definition import ParameterError, PipelineDefinition, RetryPolicy, StepDefinition
|
|
26
26
|
from dirigent_core.engine.failure import backoff_delay
|
|
27
27
|
from dirigent_core.engine.references import ReferenceScope, UnknownReference, resolve
|
|
28
|
+
from dirigent_core.engine.state import item_wise_parent
|
|
28
29
|
|
|
29
30
|
#: What a cardinality says when the document alone cannot fix it.
|
|
30
31
|
UNKNOWN: Final = "unknown"
|
|
@@ -65,7 +66,15 @@ def explain(definition: PipelineDefinition, catalog: Catalog | None = None) -> D
|
|
|
65
66
|
order = definition.topological_order()
|
|
66
67
|
shapes: dict[str, StepShape] = {}
|
|
67
68
|
for name in order:
|
|
68
|
-
shapes[name] = _step_shape(
|
|
69
|
+
shapes[name] = _step_shape(
|
|
70
|
+
name,
|
|
71
|
+
definition.steps[name],
|
|
72
|
+
params,
|
|
73
|
+
shapes,
|
|
74
|
+
catalog,
|
|
75
|
+
definition.grid_source(name),
|
|
76
|
+
item_wise_parent(definition, name),
|
|
77
|
+
)
|
|
69
78
|
rows = [shapes[name] for name in order]
|
|
70
79
|
return DocumentShape(
|
|
71
80
|
attempts_max=sum(row.max_attempts * (1 if row.elements is None else row.elements) for row in rows),
|
|
@@ -95,20 +104,23 @@ def _step_shape(
|
|
|
95
104
|
done: Mapping[str, StepShape],
|
|
96
105
|
catalog: Catalog | None,
|
|
97
106
|
source: str | None,
|
|
107
|
+
paired: str | None = None,
|
|
98
108
|
) -> StepShape:
|
|
99
109
|
"""Read one step as the work it will become, against the steps already read.
|
|
100
110
|
|
|
101
|
-
``source`` is the step a late grid waits for,
|
|
111
|
+
``source`` is the step a late grid waits for, and ``paired`` the fan-out whose items this
|
|
112
|
+
step's items wait for one by one; the row names both.
|
|
102
113
|
"""
|
|
103
114
|
cardinality, elements = _cardinality(step, params, done, source)
|
|
104
115
|
poll, deadline, from_block = _waits(step, _entry(step, catalog))
|
|
105
116
|
return StepShape(
|
|
106
117
|
name=name,
|
|
107
|
-
block=step.
|
|
118
|
+
block=step.block_id,
|
|
108
119
|
depends_on=list(step.depends_on),
|
|
109
120
|
cardinality=cardinality,
|
|
110
121
|
elements=elements,
|
|
111
122
|
grid_source=source,
|
|
123
|
+
item_wise_parent=paired,
|
|
112
124
|
reference=step.for_each if isinstance(step.for_each, str) else None,
|
|
113
125
|
items=step.items.value,
|
|
114
126
|
max_attempts=step.retry.max_attempts,
|
|
@@ -147,7 +159,7 @@ def _cardinality(
|
|
|
147
159
|
|
|
148
160
|
def _entry(step: StepDefinition, catalog: Catalog | None) -> BlockEntry | None:
|
|
149
161
|
"""Find what the catalog says about this step's block, where there is a catalog."""
|
|
150
|
-
return None if catalog is None else catalog.block(step.block)
|
|
162
|
+
return None if catalog is None or step.block is None else catalog.block(step.block)
|
|
151
163
|
|
|
152
164
|
|
|
153
165
|
def _waits(step: StepDefinition, entry: BlockEntry | None) -> tuple[timedelta | None, timedelta | None, list[str]]:
|
|
@@ -30,7 +30,7 @@ def steps_of_document(document: Mapping[str, Any]) -> list[GraphStep]:
|
|
|
30
30
|
read.append(
|
|
31
31
|
GraphStep(
|
|
32
32
|
name=str(name),
|
|
33
|
-
block=
|
|
33
|
+
block=_block_of(held),
|
|
34
34
|
depends_on=tuple(str(item) for item in cast("list[Any]", depends or ())),
|
|
35
35
|
rule=str(held.get("rule", DEFAULT_RULE)),
|
|
36
36
|
)
|
|
@@ -38,6 +38,16 @@ def steps_of_document(document: Mapping[str, Any]) -> list[GraphStep]:
|
|
|
38
38
|
return read
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def _block_of(step: Mapping[str, Any]) -> str:
|
|
42
|
+
"""Name what a step runs: its block, a source's kind, or ``reply``."""
|
|
43
|
+
source = step.get("source")
|
|
44
|
+
if isinstance(source, dict):
|
|
45
|
+
return str(cast("dict[str, Any]", source).get("kind", "source"))
|
|
46
|
+
if "reply" in step:
|
|
47
|
+
return "reply"
|
|
48
|
+
return str(step.get("block", "-"))
|
|
49
|
+
|
|
50
|
+
|
|
41
51
|
def render_graph(steps: Sequence[GraphStep]) -> list[str]:
|
|
42
52
|
"""Draw the graph as indented lines, each step under the last step it waits for.
|
|
43
53
|
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import contextlib
|
|
5
5
|
import shutil
|
|
6
6
|
import tempfile
|
|
7
|
+
import time
|
|
7
8
|
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
8
9
|
from datetime import datetime, timedelta
|
|
9
10
|
from pathlib import Path
|
|
@@ -12,12 +13,12 @@ from uuid import UUID
|
|
|
12
13
|
|
|
13
14
|
import sqlalchemy as sa
|
|
14
15
|
import yaml
|
|
15
|
-
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, SecretStr
|
|
16
17
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
17
18
|
|
|
18
19
|
from dirigent_client.enums import AttemptStatus, LogLevel, RunStatus, TriggerKind
|
|
19
20
|
from dirigent_common import JsonMap
|
|
20
|
-
from dirigent_core import migrations
|
|
21
|
+
from dirigent_core import migrations, wakeups
|
|
21
22
|
from dirigent_core.config import Settings, get_settings
|
|
22
23
|
from dirigent_core.database import create_engine, create_session_factory, session_scope
|
|
23
24
|
from dirigent_core.documents import DocumentError, load_pipeline_text, safe_load
|
|
@@ -26,11 +27,12 @@ from dirigent_core.engine.definition import PipelineDefinition
|
|
|
26
27
|
from dirigent_core.engine.runs import RunCreationError, RunWindow
|
|
27
28
|
from dirigent_core.engine.services import EngineServices
|
|
28
29
|
from dirigent_core.engine.state import in_execution_order
|
|
29
|
-
from dirigent_core.models import ArtifactRef, Connection, LogEntry, Run, RunItem, StepAttempt
|
|
30
|
+
from dirigent_core.models import ArtifactRef, Connection, LogEntry, Run, RunItem, StepAttempt, utcnow
|
|
30
31
|
from dirigent_core.pipelines import apply_document
|
|
31
32
|
from dirigent_core.plugins import load_plugin_host
|
|
32
33
|
from dirigent_core.schemas import SchemaRefused, resolve_identity, store_schema
|
|
33
34
|
from dirigent_core.secrets import generate_key
|
|
35
|
+
from dirigent_core.sources.entries import hand_entry
|
|
34
36
|
from dirigent_core.storage import scratch_prefix
|
|
35
37
|
from dirigent_core.worker import Worker
|
|
36
38
|
|
|
@@ -556,6 +558,28 @@ class Tick(BaseModel):
|
|
|
556
558
|
error: str | None
|
|
557
559
|
|
|
558
560
|
|
|
561
|
+
def _passed_through(attempt: StepAttempt, before: AttemptStatus | None) -> list[tuple[AttemptStatus, datetime]]:
|
|
562
|
+
"""Name the states a started attempt went through on its way here that no poll saw, with when.
|
|
563
|
+
|
|
564
|
+
A poll reads where an attempt stands, so an attempt claimed and settled between two polls
|
|
565
|
+
would otherwise be reported only in the state it ended in. One that started was queued
|
|
566
|
+
and ran: those are reported in order, at the moment each began.
|
|
567
|
+
"""
|
|
568
|
+
started = attempt.started_at
|
|
569
|
+
if started is None:
|
|
570
|
+
return []
|
|
571
|
+
passed: list[tuple[AttemptStatus, datetime]] = []
|
|
572
|
+
if before in (None, AttemptStatus.PENDING) and attempt.status is not AttemptStatus.QUEUED:
|
|
573
|
+
passed.append((AttemptStatus.QUEUED, attempt.available_at or started))
|
|
574
|
+
if before in (None, AttemptStatus.PENDING, AttemptStatus.QUEUED) and attempt.status not in (
|
|
575
|
+
AttemptStatus.QUEUED,
|
|
576
|
+
AttemptStatus.RUNNING,
|
|
577
|
+
AttemptStatus.WAITING,
|
|
578
|
+
):
|
|
579
|
+
passed.append((AttemptStatus.RUNNING, started))
|
|
580
|
+
return passed
|
|
581
|
+
|
|
582
|
+
|
|
559
583
|
async def _transitions(
|
|
560
584
|
sessions: async_sessionmaker[AsyncSession],
|
|
561
585
|
run_id: UUID,
|
|
@@ -582,9 +606,21 @@ async def _transitions(
|
|
|
582
606
|
# announces one and swallows the rest. The id is the identity of the thing that moved.
|
|
583
607
|
if seen.get(attempt.id) is attempt.status:
|
|
584
608
|
continue
|
|
609
|
+
before = seen.get(attempt.id)
|
|
585
610
|
seen[attempt.id] = attempt.status
|
|
586
611
|
if attempt.status is AttemptStatus.PENDING:
|
|
587
612
|
continue
|
|
613
|
+
for passed, at in _passed_through(attempt, before):
|
|
614
|
+
changed.append(
|
|
615
|
+
StepTransition(
|
|
616
|
+
step=attempt.step_name,
|
|
617
|
+
block=attempt.block_id,
|
|
618
|
+
attempt=attempt.attempt,
|
|
619
|
+
status=passed,
|
|
620
|
+
at=at,
|
|
621
|
+
item=labels.get(attempt.id),
|
|
622
|
+
)
|
|
623
|
+
)
|
|
588
624
|
settled = attempt.status in SETTLED
|
|
589
625
|
changed.append(
|
|
590
626
|
StepTransition(
|
|
@@ -618,12 +654,16 @@ async def run_document(
|
|
|
618
654
|
deadline_seconds: float = DEFAULT_DEADLINE_SECONDS,
|
|
619
655
|
keep: bool = False,
|
|
620
656
|
root: Path | None = None,
|
|
657
|
+
source: str | None = None,
|
|
658
|
+
given: JsonValue = None,
|
|
621
659
|
) -> AsyncIterator[LocalStarted | LogLine | StepTransition | LocalOutcome]:
|
|
622
660
|
"""Apply and run a document in a throwaway instance, yielding logs then the outcome.
|
|
623
661
|
|
|
624
662
|
The last item yielded is always the outcome. ``also`` holds documents to apply first
|
|
625
663
|
and not run, for a pipeline that composes another one. ``root`` names a directory to hold
|
|
626
|
-
the instance in and keep, so a later run reads what this one wrote.
|
|
664
|
+
the instance in and keep, so a later run reads what this one wrote. A pipeline with
|
|
665
|
+
sources is entered at ``source``, its only one when that is omitted, with ``given`` as the
|
|
666
|
+
input, or the source's sample when that is omitted.
|
|
627
667
|
"""
|
|
628
668
|
held = prepare_root(root) if root is not None else Path(tempfile.mkdtemp(prefix="dirigent-local-"))
|
|
629
669
|
leave = keep or root is not None
|
|
@@ -654,11 +694,13 @@ async def run_document(
|
|
|
654
694
|
)
|
|
655
695
|
version = await _current_version(session, definition.code)
|
|
656
696
|
try:
|
|
697
|
+
entry = hand_entry(definition, services.sources, source, given, received_at=utcnow())
|
|
657
698
|
run = await create_run(
|
|
658
699
|
session,
|
|
659
700
|
services,
|
|
660
701
|
version,
|
|
661
702
|
params=params or {},
|
|
703
|
+
entry=entry,
|
|
662
704
|
attribution=Attribution(kind=TriggerKind.USER, label="dg run --local"),
|
|
663
705
|
window=window,
|
|
664
706
|
log_levels=log_levels,
|
|
@@ -751,6 +793,9 @@ async def _drive(
|
|
|
751
793
|
items = _Items()
|
|
752
794
|
try:
|
|
753
795
|
while waited < deadline_seconds:
|
|
796
|
+
# The mark is taken before the read, so a commit landing between the read and the
|
|
797
|
+
# wait below still wakes it.
|
|
798
|
+
mark = wakeups.settled_mark(run_id)
|
|
754
799
|
# The status is read before the log is: a run that had settled by then wrote its
|
|
755
800
|
# last entry before it settled, so the pages below carry the whole of it.
|
|
756
801
|
tick = await _transitions(sessions, run_id, seen, items, order)
|
|
@@ -775,8 +820,9 @@ async def _drive(
|
|
|
775
820
|
)
|
|
776
821
|
return
|
|
777
822
|
interval = POLL_SECONDS if moved else min(interval * POLL_WIDEN, POLL_CEILING)
|
|
778
|
-
|
|
779
|
-
|
|
823
|
+
paused = time.monotonic()
|
|
824
|
+
await wakeups.wait_settled(mark, timeout=interval)
|
|
825
|
+
waited += time.monotonic() - paused
|
|
780
826
|
yield LocalOutcome(
|
|
781
827
|
run_id=run_id,
|
|
782
828
|
pipeline=pipeline,
|
|
@@ -14,7 +14,7 @@ from pydantic import ValidationError
|
|
|
14
14
|
from typer import _click, rich_utils
|
|
15
15
|
from typer.core import TyperGroup
|
|
16
16
|
|
|
17
|
-
from dirigent_cli import commands, triggers
|
|
17
|
+
from dirigent_cli import commands, source_commands, triggers
|
|
18
18
|
from dirigent_cli.aliases import add_alias, add_list_aliases
|
|
19
19
|
from dirigent_cli.context import CliState, state_of
|
|
20
20
|
from dirigent_cli.formatters import DEFAULT, names
|
|
@@ -128,6 +128,7 @@ 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
130
|
app.add_typer(triggers.watch_app, rich_help_panel=TRIGGER_PANEL)
|
|
131
|
+
app.add_typer(source_commands.source_app, rich_help_panel=TRIGGER_PANEL)
|
|
131
132
|
app.add_typer(triggers.trigger_document_app, rich_help_panel=TRIGGER_PANEL)
|
|
132
133
|
app.add_typer(triggers.alerts_app, rich_help_panel=TRIGGER_PANEL)
|
|
133
134
|
app.add_typer(docker_app, rich_help_panel=PROCESS_PANEL)
|
|
@@ -627,6 +628,7 @@ def prune_command(
|
|
|
627
628
|
runs: Annotated[str | None, typer.Option(help="Keep settled runs younger than this, such as 30d.")] = None,
|
|
628
629
|
logs: Annotated[str | None, typer.Option(help="Keep log entries younger than this.")] = None,
|
|
629
630
|
deliveries: Annotated[str | None, typer.Option(help="Keep webhook deliveries younger than this.")] = None,
|
|
631
|
+
inputs: Annotated[str | None, typer.Option(help="Keep the record of inputs to a source younger than this.")] = None,
|
|
630
632
|
firings: Annotated[str | None, typer.Option(help="Keep schedule firings younger than this.")] = None,
|
|
631
633
|
notifications: Annotated[str | None, typer.Option(help="Keep sent alerts younger than this.")] = None,
|
|
632
634
|
scratch: Annotated[
|
|
@@ -645,7 +647,7 @@ def prune_command(
|
|
|
645
647
|
settings = get_settings()
|
|
646
648
|
state = state_of(ctx)
|
|
647
649
|
try:
|
|
648
|
-
policy = _prune_policy(settings, runs, logs, deliveries, firings, notifications, scratch=scratch)
|
|
650
|
+
policy = _prune_policy(settings, runs, logs, deliveries, inputs, firings, notifications, scratch=scratch)
|
|
649
651
|
except ValueError as error:
|
|
650
652
|
refuse(INVALID_AGE, status=2, title="Invalid age", detail=str(error))
|
|
651
653
|
raise typer.Exit(code=2) from error
|
|
@@ -657,6 +659,7 @@ def _prune_policy(
|
|
|
657
659
|
runs: str | None,
|
|
658
660
|
logs: str | None,
|
|
659
661
|
deliveries: str | None,
|
|
662
|
+
inputs: str | None,
|
|
660
663
|
firings: str | None,
|
|
661
664
|
notifications: str | None,
|
|
662
665
|
*,
|
|
@@ -673,6 +676,7 @@ def _prune_policy(
|
|
|
673
676
|
runs=age(runs, settings.retention_runs),
|
|
674
677
|
logs=age(logs, settings.retention_logs),
|
|
675
678
|
deliveries=age(deliveries, settings.retention_deliveries),
|
|
679
|
+
inputs=age(inputs, settings.retention_inputs),
|
|
676
680
|
firings=age(firings, settings.retention_firings),
|
|
677
681
|
notifications=age(notifications, settings.retention_notifications),
|
|
678
682
|
scratch=scratch,
|
|
@@ -939,9 +943,11 @@ def dev(
|
|
|
939
943
|
checkout is not needed; both may be given, and the directories go in first.
|
|
940
944
|
|
|
941
945
|
--seed fills the instance from a directory of documents the moment it answers: the
|
|
942
|
-
connections a file or a document declares
|
|
943
|
-
applied with its schedules and watches paused
|
|
944
|
-
|
|
946
|
+
connections a file or a document declares and the standalone JSON Schema files are created
|
|
947
|
+
first, then every document is applied with its schedules and watches paused, and one refused
|
|
948
|
+
for naming something a later document supplies is applied again once the rest are in. A
|
|
949
|
+
document an instance will not store is reported and passed over, because a corpus holds
|
|
950
|
+
those on purpose.
|
|
945
951
|
"""
|
|
946
952
|
import asyncio
|
|
947
953
|
import os
|
|
@@ -86,6 +86,10 @@ CANNOT_PROMPT_SET = CLI.define("cannot_prompt_set", "--json cannot prompt for {f
|
|
|
86
86
|
|
|
87
87
|
PARAMS_REFUSED = CLI.define("params_refused", "{detail}")
|
|
88
88
|
|
|
89
|
+
INPUT_REFUSED = CLI.define("input_refused", "--input is not JSON or YAML: {detail}")
|
|
90
|
+
|
|
91
|
+
INPUT_FILE_UNREADABLE = CLI.define("input_file_unreadable", "--input names {path}, which cannot be read: {detail}")
|
|
92
|
+
|
|
89
93
|
TRIGGERS_DOCUMENT_NOT_RUNNABLE = CLI.define(
|
|
90
94
|
"triggers_document_not_runnable",
|
|
91
95
|
"a triggers document declares clocks for a pipeline and cannot be run; run the pipeline it names",
|
|
@@ -320,3 +324,14 @@ DATABASE_BEHIND = HEALTH.define(
|
|
|
320
324
|
)
|
|
321
325
|
|
|
322
326
|
DATABASE_OK = HEALTH.define("database.ok", "the database at {where} answers, schema {stamped}")
|
|
327
|
+
|
|
328
|
+
ONE_INPUT_BODY = CLI.define("one_input_body", "a call takes one body: --body, --text or --form")
|
|
329
|
+
|
|
330
|
+
TAKES_NAME_EQUALS_VALUE = CLI.define("takes_name_equals_value", "{entry} is not name=value")
|
|
331
|
+
|
|
332
|
+
SOURCE_NOT_CALLABLE = CLI.define(
|
|
333
|
+
"source_not_callable",
|
|
334
|
+
"source {code} of pipeline {pipeline} is of kind {kind}, which this instance does not receive",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
SOURCE_UNREACHABLE = CLI.define("source_unreachable", "{url} could not be called: {detail}")
|
|
@@ -73,6 +73,10 @@ class StepShape(BaseModel):
|
|
|
73
73
|
grid_source: str | None = None
|
|
74
74
|
"""The step a late grid waits for before it expands, or null for a grid fixed at creation."""
|
|
75
75
|
|
|
76
|
+
item_wise_parent: str | None = None
|
|
77
|
+
"""The fan-out whose matching item each of this step's items waits for, one by one, or null
|
|
78
|
+
when the step waits for its prerequisites whole."""
|
|
79
|
+
|
|
76
80
|
reference: str | None = None
|
|
77
81
|
"""The ``for_each`` this step maps over, where it names one rather than listing it."""
|
|
78
82
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The seeding goes through the API, so every apply meets the same preflight a person's apply
|
|
4
4
|
meets, and a refusal is a record rather than a failure: a corpus is expected to hold
|
|
5
|
-
documents an instance will not store.
|
|
5
|
+
documents an instance will not store. A corpus goes in as connection files, then the
|
|
6
|
+
standalone JSON Schemas beside its documents, then the documents; a document refused for
|
|
7
|
+
naming something a later one supplies is applied again once the rest are in.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
@@ -13,8 +15,9 @@ from typing import Any, cast
|
|
|
13
15
|
from dirigent_cli.local import ConnectionSpec
|
|
14
16
|
from dirigent_client import Dirigent, DirigentError, PlanAction, ProvenanceSource, declared_label
|
|
15
17
|
from dirigent_common import JsonMap
|
|
16
|
-
from dirigent_core.documents import CARRIED, SUFFIXES, is_document, readable
|
|
18
|
+
from dirigent_core.documents import CARRIED, SUFFIXES, is_document, readable
|
|
17
19
|
from dirigent_core.protocol import Record, make
|
|
20
|
+
from dirigent_core.schemas import code_from_id
|
|
18
21
|
|
|
19
22
|
__all__ = ["CARRIED", "SUFFIXES", "is_document", "readable", "seed_directories", "seed_installed", "specs"]
|
|
20
23
|
|
|
@@ -36,49 +39,117 @@ async def seed_directories(client: Dirigent, directories: Sequence[Path]) -> Asy
|
|
|
36
39
|
The closing record's counts are read off the records the seeding emitted, so a reader of
|
|
37
40
|
the stream and this summary never disagree.
|
|
38
41
|
"""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
kind = record["kind"]
|
|
44
|
-
if kind == "seed.applied":
|
|
45
|
-
pipelines += 1
|
|
46
|
-
elif kind == "seed.refused":
|
|
47
|
-
refused += 1
|
|
48
|
-
elif kind == "seed.connection":
|
|
49
|
-
connections.add(str(record["connection"]))
|
|
50
|
-
yield record
|
|
42
|
+
files = [(str(path), raw) for directory in directories for path, raw in readable(directory)]
|
|
43
|
+
tally = _Tally()
|
|
44
|
+
async for record in _seed(client, files):
|
|
45
|
+
yield tally.count(record)
|
|
51
46
|
yield make(
|
|
52
47
|
"seed.done",
|
|
53
48
|
at=datetime.now(UTC),
|
|
54
49
|
message="seeded",
|
|
55
50
|
directories=[str(one) for one in directories],
|
|
56
|
-
|
|
57
|
-
refused=refused,
|
|
58
|
-
connections=sorted(connections),
|
|
51
|
+
**tally.counts(),
|
|
59
52
|
)
|
|
60
53
|
|
|
61
54
|
|
|
62
|
-
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
55
|
+
class _Tally:
|
|
56
|
+
"""What a seeding did, read off the records it emitted, so a reader and the summary agree."""
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
"""Start from nothing seeded."""
|
|
60
|
+
self.pipelines = 0
|
|
61
|
+
self.refused = 0
|
|
62
|
+
self.connections: set[str] = set()
|
|
63
|
+
self.schemas: set[str] = set()
|
|
64
|
+
|
|
65
|
+
def count(self, record: Record) -> Record:
|
|
66
|
+
"""Count one record, and hand it back to be emitted."""
|
|
67
|
+
match record["kind"]:
|
|
68
|
+
case "seed.applied":
|
|
69
|
+
self.pipelines += 1
|
|
70
|
+
case "seed.refused":
|
|
71
|
+
self.refused += 1
|
|
72
|
+
case "seed.connection":
|
|
73
|
+
self.connections.add(str(record["connection"]))
|
|
74
|
+
case "seed.schema":
|
|
75
|
+
self.schemas.add(str(record["schema"]))
|
|
76
|
+
case _:
|
|
77
|
+
pass
|
|
78
|
+
return record
|
|
79
|
+
|
|
80
|
+
def counts(self) -> dict[str, Any]:
|
|
81
|
+
"""The closing record's fields."""
|
|
82
|
+
return {
|
|
83
|
+
"pipelines": self.pipelines,
|
|
84
|
+
"refused": self.refused,
|
|
85
|
+
"connections": sorted(self.connections),
|
|
86
|
+
"schemas": sorted(self.schemas),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _seed(client: Dirigent, files: Sequence[tuple[str, JsonMap]]) -> AsyncIterator[Record]:
|
|
91
|
+
"""Seed a corpus: its connection files, its standalone schemas, then its documents.
|
|
92
|
+
|
|
93
|
+
The connections and the schemas go in before any document, so a document that names one
|
|
94
|
+
passes the preflight that reads it. A document refused in one pass is applied again in
|
|
95
|
+
the next, since what it names may be a pipeline or a schema a later document supplied, and
|
|
96
|
+
the passes end when one stores nothing new; what is still refused is reported then.
|
|
97
|
+
"""
|
|
98
|
+
for origin, raw in files:
|
|
99
|
+
if not is_document(raw):
|
|
100
|
+
async for record in _connections(client, specs(raw.get("connections")), origin):
|
|
101
|
+
yield record
|
|
102
|
+
for origin, raw in files:
|
|
103
|
+
code = _schema_file(raw, origin)
|
|
104
|
+
if code is not None:
|
|
105
|
+
yield await _standalone_schema(client, code, raw, origin)
|
|
106
|
+
waiting: list[tuple[str, JsonMap, Record]] = []
|
|
107
|
+
for origin, raw in files:
|
|
108
|
+
if is_document(raw):
|
|
109
|
+
async for record in _carried(client, raw, origin):
|
|
110
|
+
yield record
|
|
111
|
+
applied = await _apply(client, raw, origin)
|
|
112
|
+
if applied["kind"] == "seed.applied":
|
|
113
|
+
yield applied
|
|
114
|
+
else:
|
|
115
|
+
waiting.append((origin, raw, applied))
|
|
116
|
+
while waiting:
|
|
117
|
+
still: list[tuple[str, JsonMap, Record]] = []
|
|
118
|
+
for origin, raw, _refusal in waiting:
|
|
119
|
+
record = await _apply(client, raw, origin)
|
|
120
|
+
if record["kind"] == "seed.applied":
|
|
121
|
+
yield record
|
|
122
|
+
else:
|
|
123
|
+
still.append((origin, raw, record))
|
|
124
|
+
if len(still) == len(waiting):
|
|
125
|
+
break
|
|
126
|
+
waiting = still
|
|
127
|
+
for _origin, _raw, refusal in waiting:
|
|
128
|
+
yield refusal
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _schema_file(raw: JsonMap, origin: str) -> str | None:
|
|
132
|
+
"""Name the code a standalone JSON Schema file is stored under, or None when the file is not one.
|
|
133
|
+
|
|
134
|
+
A file that is neither a document nor a connection file and declares a string ``$id`` is a
|
|
135
|
+
schema, coded the way ``dg schema create`` codes it: from the ``$id``, else the filename.
|
|
67
136
|
"""
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
137
|
+
if is_document(raw) or "connections" in raw or not isinstance(raw.get("$id"), str):
|
|
138
|
+
return None
|
|
139
|
+
return code_from_id(cast("str", raw["$id"])) or code_from_id(Path(origin.rpartition(":")[2]).name)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _standalone_schema(client: Dirigent, code: str, body: JsonMap, origin: str) -> Record:
|
|
143
|
+
"""Store one schema file, and say so, or say why the instance would not."""
|
|
144
|
+
try:
|
|
145
|
+
await _schema(client, code, body)
|
|
146
|
+
except DirigentError as error:
|
|
147
|
+
return _refused(origin, f"schema {code}: {_reason(error)}")
|
|
148
|
+
return make("seed.schema", at=datetime.now(UTC), message="schema", schema=code, origin=origin)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _carried(client: Dirigent, raw: JsonMap, origin: str) -> AsyncIterator[Record]:
|
|
152
|
+
"""Create the connections and the schemas one document carries."""
|
|
82
153
|
async for record in _connections(client, specs(raw.get("connections")), origin):
|
|
83
154
|
yield record
|
|
84
155
|
for code, body in _schemas(raw).items():
|
|
@@ -86,7 +157,6 @@ async def _document(client: Dirigent, raw: JsonMap, origin: str) -> AsyncIterato
|
|
|
86
157
|
await _schema(client, code, body)
|
|
87
158
|
except DirigentError as error:
|
|
88
159
|
yield _refused(origin, f"schema {code}: {_reason(error)}")
|
|
89
|
-
yield await _apply(client, raw, origin)
|
|
90
160
|
|
|
91
161
|
|
|
92
162
|
def _schemas(raw: JsonMap) -> dict[str, JsonMap]:
|
|
@@ -182,37 +252,22 @@ def _reason(error: DirigentError) -> str:
|
|
|
182
252
|
|
|
183
253
|
|
|
184
254
|
async def seed_installed(client: Dirigent) -> AsyncIterator[Record]:
|
|
185
|
-
"""
|
|
255
|
+
"""Seed every installed corpus the way a directory is seeded, files beside the documents included.
|
|
186
256
|
|
|
187
|
-
The
|
|
188
|
-
checkout has to be present. A
|
|
257
|
+
The corpus is what this build ships, so nothing is named on the command line and no
|
|
258
|
+
checkout has to be present. A file is attributed to the plugin that carries it.
|
|
189
259
|
"""
|
|
190
260
|
from dirigent_core.plugins import load_plugin_host
|
|
191
261
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
parsed = safe_load(entry.source)
|
|
198
|
-
if not isinstance(parsed, dict):
|
|
199
|
-
continue
|
|
200
|
-
plugins.add(entry.plugin)
|
|
201
|
-
async for record in _document(client, cast("JsonMap", parsed), f"{entry.plugin}:{entry.path}"):
|
|
202
|
-
kind = record["kind"]
|
|
203
|
-
if kind == "seed.applied":
|
|
204
|
-
pipelines += 1
|
|
205
|
-
elif kind == "seed.refused":
|
|
206
|
-
refused += 1
|
|
207
|
-
elif kind == "seed.connection":
|
|
208
|
-
connections.add(str(record["connection"]))
|
|
209
|
-
yield record
|
|
262
|
+
shipped = load_plugin_host().example_files()
|
|
263
|
+
files = [(f"{one.plugin}:{one.path}", one.raw) for one in shipped]
|
|
264
|
+
tally = _Tally()
|
|
265
|
+
async for record in _seed(client, files):
|
|
266
|
+
yield tally.count(record)
|
|
210
267
|
yield make(
|
|
211
268
|
"seed.done",
|
|
212
269
|
at=datetime.now(UTC),
|
|
213
270
|
message="seeded",
|
|
214
|
-
plugins=sorted(
|
|
215
|
-
|
|
216
|
-
refused=refused,
|
|
217
|
-
connections=sorted(connections),
|
|
271
|
+
plugins=sorted({one.plugin for one in shipped if is_document(one.raw)}),
|
|
272
|
+
**tally.counts(),
|
|
218
273
|
)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""The source command group: where a pipeline's inputs enter, what has arrived, and calling one."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Final
|
|
6
|
+
|
|
7
|
+
import httpx2
|
|
8
|
+
import typer
|
|
9
|
+
from pydantic import JsonValue
|
|
10
|
+
|
|
11
|
+
from dirigent_cli.commands import fail, paged
|
|
12
|
+
from dirigent_cli.context import client_for, state_of
|
|
13
|
+
from dirigent_cli.messages import ONE_INPUT_BODY, SOURCE_NOT_CALLABLE, SOURCE_UNREACHABLE, TAKES_NAME_EQUALS_VALUE
|
|
14
|
+
from dirigent_cli.output import emit_fact, emit_one, emit_records
|
|
15
|
+
from dirigent_client import DEFAULT_CONNECT_TIMEOUT
|
|
16
|
+
from dirigent_plugin.sources import Route
|
|
17
|
+
|
|
18
|
+
#: Where ``dg source call`` reads the token a source was given when ``--source-token`` names none.
|
|
19
|
+
TOKEN_ENV: Final = "DIRIGENT_SOURCE_TOKEN"
|
|
20
|
+
|
|
21
|
+
#: How long a call waits for its answer: an endpoint answers once its run exists, not once it settles.
|
|
22
|
+
CALL_TIMEOUT: Final = httpx2.Timeout(30.0, connect=DEFAULT_CONNECT_TIMEOUT)
|
|
23
|
+
|
|
24
|
+
source_app = typer.Typer(
|
|
25
|
+
name="source",
|
|
26
|
+
help="Where a pipeline's inputs enter: what each listens to, what has arrived, and calling one.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
PipelineArg = Annotated[str, typer.Argument(help="The pipeline the source belongs to.")]
|
|
31
|
+
SourceArg = Annotated[str, typer.Argument(help="The source's step code.")]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@source_app.command("list")
|
|
35
|
+
def source_list(
|
|
36
|
+
ctx: typer.Context,
|
|
37
|
+
pipeline: Annotated[
|
|
38
|
+
str | None, typer.Argument(help="The pipeline whose sources to list; every one when unset.")
|
|
39
|
+
] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""List sources with their state and what they have taken, never their tokens."""
|
|
42
|
+
with client_for(state_of(ctx)) as dg:
|
|
43
|
+
rows = list(paged(lambda after, size: dg.call(dg.sources.list(pipeline, after=after, limit=size)), None))
|
|
44
|
+
emit_records("source", rows)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@source_app.command("show")
|
|
48
|
+
def source_show(ctx: typer.Context, pipeline: PipelineArg, code: SourceArg) -> None:
|
|
49
|
+
"""Show one source: its route, its state, and what it has taken."""
|
|
50
|
+
with client_for(state_of(ctx)) as dg:
|
|
51
|
+
row = dg.call(dg.sources.get(pipeline, code))
|
|
52
|
+
emit_one("source", row)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@source_app.command("pause")
|
|
56
|
+
def source_pause(ctx: typer.Context, pipeline: PipelineArg, code: SourceArg) -> None:
|
|
57
|
+
"""Refuse new inputs with 503, keeping the token and the counts; runs in flight carry on."""
|
|
58
|
+
with client_for(state_of(ctx)) as dg:
|
|
59
|
+
row = dg.call(dg.sources.pause(pipeline, code))
|
|
60
|
+
emit_fact("source.paused", message="paused", code=code, pipeline=pipeline, state=row.state.value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@source_app.command("resume")
|
|
64
|
+
def source_resume(ctx: typer.Context, pipeline: PipelineArg, code: SourceArg) -> None:
|
|
65
|
+
"""Take inputs again on the token that was already issued."""
|
|
66
|
+
with client_for(state_of(ctx)) as dg:
|
|
67
|
+
row = dg.call(dg.sources.resume(pipeline, code))
|
|
68
|
+
emit_fact("source.resumed", message="resumed", code=code, pipeline=pipeline, state=row.state.value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@source_app.command("rotate-token")
|
|
72
|
+
def source_rotate(ctx: typer.Context, pipeline: PipelineArg, code: SourceArg) -> None:
|
|
73
|
+
"""Mint a new token and forget the old one; every caller has to be given the new one."""
|
|
74
|
+
with client_for(state_of(ctx)) as dg:
|
|
75
|
+
minted = dg.call(dg.sources.rotate_token(pipeline, code))
|
|
76
|
+
base = dg.url
|
|
77
|
+
emit_fact(
|
|
78
|
+
"source.token_rotated",
|
|
79
|
+
message="rotated",
|
|
80
|
+
code=code,
|
|
81
|
+
pipeline=pipeline,
|
|
82
|
+
url=f"{base.rstrip('/')}{minted.url_path}",
|
|
83
|
+
token=minted.token,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@source_app.command("inputs")
|
|
88
|
+
def source_inputs(
|
|
89
|
+
ctx: typer.Context,
|
|
90
|
+
pipeline: PipelineArg,
|
|
91
|
+
code: SourceArg,
|
|
92
|
+
limit: Annotated[int, typer.Option(help="How many inputs to show, newest first.")] = 50,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""List what has arrived at a source, newest first, refusals included."""
|
|
95
|
+
with client_for(state_of(ctx)) as dg:
|
|
96
|
+
rows = list(
|
|
97
|
+
paged(lambda after, size: dg.call(dg.sources.inputs(pipeline, code, after=after, limit=size)), limit)
|
|
98
|
+
)
|
|
99
|
+
emit_records("source.input", rows)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@source_app.command("call")
|
|
103
|
+
def source_call(
|
|
104
|
+
ctx: typer.Context,
|
|
105
|
+
pipeline: PipelineArg,
|
|
106
|
+
code: SourceArg,
|
|
107
|
+
data: Annotated[str | None, typer.Option("--body", help="A JSON body, or @file to read one from a file.")] = None,
|
|
108
|
+
text: Annotated[str | None, typer.Option("--text", help="A text body, or @file to read one from a file.")] = None,
|
|
109
|
+
form: Annotated[
|
|
110
|
+
list[str] | None, typer.Option("--form", help="One name=value of a urlencoded form body; repeat for more.")
|
|
111
|
+
] = None,
|
|
112
|
+
method: Annotated[
|
|
113
|
+
str | None, typer.Option("--method", "-X", help="The method to call with; the source's first unless set.")
|
|
114
|
+
] = None,
|
|
115
|
+
path_params: Annotated[
|
|
116
|
+
list[str] | None, typer.Option("--path-param", "-p", help="One name=value the path template takes.")
|
|
117
|
+
] = None,
|
|
118
|
+
query: Annotated[list[str] | None, typer.Option("--query", "-q", help="One name=value of the query.")] = None,
|
|
119
|
+
token: Annotated[
|
|
120
|
+
str | None,
|
|
121
|
+
typer.Option("--source-token", envvar=TOKEN_ENV, help="The source's own token, not the CLI's credential."),
|
|
122
|
+
] = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Call a received source the way its callers do, and write down what it answered.
|
|
125
|
+
|
|
126
|
+
The call carries the source's own token, never this CLI's: an endpoint authenticates its
|
|
127
|
+
caller as the source, not as a person.
|
|
128
|
+
"""
|
|
129
|
+
if sum(value is not None for value in (data, text, form)) > 1:
|
|
130
|
+
fail(ONE_INPUT_BODY)
|
|
131
|
+
with client_for(state_of(ctx)) as dg:
|
|
132
|
+
row = dg.call(dg.sources.get(pipeline, code))
|
|
133
|
+
base = dg.url
|
|
134
|
+
if row.url_path is None or not row.methods:
|
|
135
|
+
fail(SOURCE_NOT_CALLABLE, code=repr(code), pipeline=repr(pipeline), kind=repr(row.kind))
|
|
136
|
+
chosen = (method or row.methods[0]).upper()
|
|
137
|
+
url = base.rstrip("/") + Route(methods=tuple(row.methods), template=row.url_path).fill(_pairs(path_params))
|
|
138
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
139
|
+
content: bytes | None = None
|
|
140
|
+
if data is not None:
|
|
141
|
+
content = _read(data).encode()
|
|
142
|
+
headers["Content-Type"] = "application/json"
|
|
143
|
+
elif text is not None:
|
|
144
|
+
content = _read(text).encode()
|
|
145
|
+
headers["Content-Type"] = "text/plain; charset=utf-8"
|
|
146
|
+
try:
|
|
147
|
+
with httpx2.Client(timeout=CALL_TIMEOUT) as http:
|
|
148
|
+
answered = http.request(
|
|
149
|
+
chosen,
|
|
150
|
+
url,
|
|
151
|
+
headers=headers,
|
|
152
|
+
params=_pairs(query),
|
|
153
|
+
content=content,
|
|
154
|
+
data=_pairs(form) if form is not None else None,
|
|
155
|
+
)
|
|
156
|
+
except httpx2.HTTPError as error:
|
|
157
|
+
fail(SOURCE_UNREACHABLE, url=url, detail=str(error))
|
|
158
|
+
emit_fact(
|
|
159
|
+
"source.called",
|
|
160
|
+
message=f"answered {answered.status_code}",
|
|
161
|
+
code=code,
|
|
162
|
+
pipeline=pipeline,
|
|
163
|
+
method=chosen,
|
|
164
|
+
url=url,
|
|
165
|
+
status=answered.status_code,
|
|
166
|
+
body=_body_of(answered),
|
|
167
|
+
)
|
|
168
|
+
if answered.status_code >= 400:
|
|
169
|
+
raise typer.Exit(code=1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _read(value: str) -> str:
|
|
173
|
+
"""Read a value, or the file an ``@`` before it names."""
|
|
174
|
+
if value.startswith("@"):
|
|
175
|
+
return Path(value[1:]).expanduser().read_text()
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _pairs(entries: list[str] | None) -> dict[str, str]:
|
|
180
|
+
"""Read repeated ``name=value`` options into a mapping."""
|
|
181
|
+
pairs: dict[str, str] = {}
|
|
182
|
+
for entry in entries or ():
|
|
183
|
+
name, equals, value = entry.partition("=")
|
|
184
|
+
if not equals or not name:
|
|
185
|
+
fail(TAKES_NAME_EQUALS_VALUE, entry=repr(entry))
|
|
186
|
+
pairs[name] = value
|
|
187
|
+
return pairs
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _body_of(answered: httpx2.Response) -> JsonValue:
|
|
191
|
+
"""Read what an endpoint answered with: JSON when it says so, its text otherwise, nothing when empty."""
|
|
192
|
+
if not answered.content:
|
|
193
|
+
return None
|
|
194
|
+
if "json" in answered.headers.get("content-type", ""):
|
|
195
|
+
try:
|
|
196
|
+
parsed: JsonValue = json.loads(answered.content)
|
|
197
|
+
except ValueError:
|
|
198
|
+
return answered.text
|
|
199
|
+
return parsed
|
|
200
|
+
return answered.text
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
__all__ = ["TOKEN_ENV", "source_app"]
|
|
@@ -250,6 +250,20 @@ def _issued(record: Record) -> RenderableType | None:
|
|
|
250
250
|
)
|
|
251
251
|
|
|
252
252
|
|
|
253
|
+
def _source_token(record: Record) -> RenderableType | None:
|
|
254
|
+
"""Hand over a source's minted token with where it is presented, and that it is shown once."""
|
|
255
|
+
token = record.get("token")
|
|
256
|
+
if not token:
|
|
257
|
+
return None
|
|
258
|
+
return Group(
|
|
259
|
+
"",
|
|
260
|
+
f" [bold]{escape(str(record.get('url') or ''))}[/]",
|
|
261
|
+
f" [bold]Authorization: Bearer {escape(str(token))}[/]",
|
|
262
|
+
"",
|
|
263
|
+
"[yellow]This is the only time the token is shown.[/] The instance stores only its hash.",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
253
267
|
def _files(record: Record) -> list[RenderableType]:
|
|
254
268
|
"""List what was written, named against the directory it was written into."""
|
|
255
269
|
written = _texts(record, "files")
|
|
@@ -467,8 +481,10 @@ def _shape(record: Record) -> RenderableType | None:
|
|
|
467
481
|
|
|
468
482
|
|
|
469
483
|
def _width(row: schemas.StepShape) -> str:
|
|
470
|
-
"""Say how wide a step is, and where one bad item leaves the rest
|
|
484
|
+
"""Say how wide a step is, whether its items go one by one, and where one bad item leaves the rest running."""
|
|
471
485
|
drawn = escape(str(row.cardinality))
|
|
486
|
+
if row.item_wise_parent is not None:
|
|
487
|
+
drawn = f"{drawn} {muted('item by item')}"
|
|
472
488
|
return f"{drawn} {muted('continue')}" if row.items == "continue" else drawn
|
|
473
489
|
|
|
474
490
|
|
|
@@ -548,6 +564,7 @@ RENDERERS: Final[Mapping[str, Callable[[Record], RenderableType | None]]] = {
|
|
|
548
564
|
"validation.shape": _shape,
|
|
549
565
|
"error": _refusal,
|
|
550
566
|
"token.issued": _issued,
|
|
567
|
+
"source.token_rotated": _source_token,
|
|
551
568
|
"process": _issued,
|
|
552
569
|
"project.scaffolded": _scaffolded,
|
|
553
570
|
"instance.initialised": _initialised,
|
|
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.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/operator.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|