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.
Files changed (38) hide show
  1. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/PKG-INFO +9 -9
  2. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/pyproject.toml +9 -9
  3. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/pyproject.toml.orig +9 -9
  4. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/commands.py +64 -2
  5. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/explain.py +16 -4
  6. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/graph.py +11 -1
  7. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/local.py +52 -6
  8. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/main.py +11 -5
  9. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/messages.py +15 -0
  10. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/schemas.py +4 -0
  11. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/seeding.py +117 -62
  12. dirigent_cli-0.24.0/src/dirigent_cli/source_commands.py +203 -0
  13. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/summaries.py +18 -1
  14. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/LICENSE +0 -0
  15. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/README.md +0 -0
  16. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/__init__.py +0 -0
  17. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/aliases.py +0 -0
  18. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/context.py +0 -0
  19. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/formatters.py +0 -0
  20. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/health.py +0 -0
  21. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/init_form.py +0 -0
  22. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/output.py +0 -0
  23. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/params.py +0 -0
  24. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/profiles.py +0 -0
  25. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/project.py +0 -0
  26. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/py.typed +0 -0
  27. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/reaper.py +0 -0
  28. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/scaffold.py +0 -0
  29. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/sources.py +0 -0
  30. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/starters.py +0 -0
  31. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/stream.py +0 -0
  32. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  33. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  34. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  35. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  36. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  37. {dirigent_cli-0.23.4 → dirigent_cli-0.24.0}/src/dirigent_cli/timing.py +0 -0
  38. {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.23.4
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.23.4
10
- Requires-Dist: dirigent-blocks==0.23.4
11
- Requires-Dist: dirigent-client==0.23.4
12
- Requires-Dist: dirigent-common==0.23.4
13
- Requires-Dist: dirigent-core==0.23.4
14
- Requires-Dist: dirigent-examples==0.23.4
15
- Requires-Dist: dirigent-plugin==0.23.4
16
- Requires-Dist: dirigent-server==0.23.4
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.23.4"
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.23.4",
15
- "dirigent-blocks==0.23.4",
16
- "dirigent-client==0.23.4",
17
- "dirigent-common==0.23.4",
18
- "dirigent-core==0.23.4",
19
- "dirigent-examples==0.23.4",
20
- "dirigent-plugin==0.23.4",
21
- "dirigent-server==0.23.4",
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.23.4"
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.23.4",
15
- "dirigent-blocks==0.23.4",
16
- "dirigent-client==0.23.4",
17
- "dirigent-common==0.23.4",
18
- "dirigent-core==0.23.4",
19
- "dirigent-examples==0.23.4",
20
- "dirigent-plugin==0.23.4",
21
- "dirigent-server==0.23.4",
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.block, depends_on=tuple(step.depends_on), rule=step.rule.value)
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(dg.pipelines.run(code, params=params, window=covered, log_levels=levels, priority=wanted))
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(name, definition.steps[name], params, shapes, catalog, definition.grid_source(name))
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, which the row names.
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.block,
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=str(held.get("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
- await asyncio.sleep(interval)
779
- waited += interval
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 are created first, then every document is
943
- applied with its schedules and watches paused. A document an instance will not store is reported
944
- and passed over, because a corpus holds those on purpose.
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, safe_load
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
- pipelines = 0
40
- refused = 0
41
- connections: set[str] = set()
42
- async for record in _walk(client, directories):
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
- pipelines=pipelines,
57
- refused=refused,
58
- connections=sorted(connections),
51
+ **tally.counts(),
59
52
  )
60
53
 
61
54
 
62
- async def _walk(client: Dirigent, directories: Sequence[Path]) -> AsyncIterator[Record]:
63
- """Seed each directory in the order given: the connections it declares, then its documents.
64
-
65
- A directory's own connection files go in before any of its documents, so a document that
66
- names one of them passes the preflight that reads it.
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
- for directory in directories:
69
- files = readable(directory)
70
- for path, raw in files:
71
- if not is_document(raw):
72
- async for record in _connections(client, specs(raw.get("connections")), str(path)):
73
- yield record
74
- for path, raw in files:
75
- if is_document(raw):
76
- async for record in _document(client, raw, str(path)):
77
- yield record
78
-
79
-
80
- async def _document(client: Dirigent, raw: JsonMap, origin: str) -> AsyncIterator[Record]:
81
- """Create what one document carries, then apply the document without those sections."""
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
- """Apply every document of every installed corpus, the way a directory's documents go in.
255
+ """Seed every installed corpus the way a directory is seeded, files beside the documents included.
186
256
 
187
- The catalogue is what this build ships, so nothing is named on the command line and no
188
- checkout has to be present. A document is attributed to the plugin that carries it.
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
- pipelines = 0
193
- refused = 0
194
- connections: set[str] = set()
195
- plugins: set[str] = set()
196
- for entry in load_plugin_host().examples():
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(plugins),
215
- pipelines=pipelines,
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 of the batch running."""
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