dirigent-cli 0.26.1__tar.gz → 0.27.1__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.26.1 → dirigent_cli-0.27.1}/PKG-INFO +9 -9
  2. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/pyproject.toml +9 -9
  3. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/pyproject.toml.orig +9 -9
  4. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/commands.py +22 -9
  5. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/local.py +12 -1
  6. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/main.py +3 -8
  7. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/messages.py +0 -5
  8. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/seeding.py +4 -3
  9. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/source_commands.py +37 -3
  10. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/sources.py +32 -0
  11. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/summaries.py +11 -2
  12. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/triggers.py +5 -237
  13. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/LICENSE +0 -0
  14. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/README.md +0 -0
  15. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/__init__.py +0 -0
  16. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/aliases.py +0 -0
  17. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/context.py +0 -0
  18. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/explain.py +0 -0
  19. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/formatters.py +0 -0
  20. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/graph.py +0 -0
  21. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/health.py +0 -0
  22. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/init_form.py +0 -0
  23. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/output.py +0 -0
  24. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/params.py +0 -0
  25. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/profiles.py +0 -0
  26. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/project.py +0 -0
  27. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/py.typed +0 -0
  28. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/reaper.py +0 -0
  29. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/scaffold.py +0 -0
  30. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/schemas.py +0 -0
  31. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/starters.py +0 -0
  32. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/stream.py +0 -0
  33. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  34. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  35. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  36. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  37. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  38. {dirigent_cli-0.26.1 → dirigent_cli-0.27.1}/src/dirigent_cli/timing.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.26.1
3
+ Version: 0.27.1
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.26.1
10
- Requires-Dist: dirigent-blocks==0.26.1
11
- Requires-Dist: dirigent-client==0.26.1
12
- Requires-Dist: dirigent-common==0.26.1
13
- Requires-Dist: dirigent-core==0.26.1
14
- Requires-Dist: dirigent-examples==0.26.1
15
- Requires-Dist: dirigent-plugin==0.26.1
16
- Requires-Dist: dirigent-server==0.26.1
9
+ Requires-Dist: dirigent-block-execute==0.27.1
10
+ Requires-Dist: dirigent-blocks==0.27.1
11
+ Requires-Dist: dirigent-client==0.27.1
12
+ Requires-Dist: dirigent-common==0.27.1
13
+ Requires-Dist: dirigent-core==0.27.1
14
+ Requires-Dist: dirigent-examples==0.27.1
15
+ Requires-Dist: dirigent-plugin==0.27.1
16
+ Requires-Dist: dirigent-server==0.27.1
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.26.1"
3
+ version = "0.27.1"
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.26.1",
15
- "dirigent-blocks==0.26.1",
16
- "dirigent-client==0.26.1",
17
- "dirigent-common==0.26.1",
18
- "dirigent-core==0.26.1",
19
- "dirigent-examples==0.26.1",
20
- "dirigent-plugin==0.26.1",
21
- "dirigent-server==0.26.1",
14
+ "dirigent-block-execute==0.27.1",
15
+ "dirigent-blocks==0.27.1",
16
+ "dirigent-client==0.27.1",
17
+ "dirigent-common==0.27.1",
18
+ "dirigent-core==0.27.1",
19
+ "dirigent-examples==0.27.1",
20
+ "dirigent-plugin==0.27.1",
21
+ "dirigent-server==0.27.1",
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.26.1"
3
+ version = "0.27.1"
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.26.1",
15
- "dirigent-blocks==0.26.1",
16
- "dirigent-client==0.26.1",
17
- "dirigent-common==0.26.1",
18
- "dirigent-core==0.26.1",
19
- "dirigent-examples==0.26.1",
20
- "dirigent-plugin==0.26.1",
21
- "dirigent-server==0.26.1",
14
+ "dirigent-block-execute==0.27.1",
15
+ "dirigent-blocks==0.27.1",
16
+ "dirigent-client==0.27.1",
17
+ "dirigent-common==0.27.1",
18
+ "dirigent-core==0.27.1",
19
+ "dirigent-examples==0.27.1",
20
+ "dirigent-plugin==0.27.1",
21
+ "dirigent-server==0.27.1",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -136,10 +136,12 @@ from dirigent_cli.sources import (
136
136
  SourceError,
137
137
  looks_like_a_document,
138
138
  parse_document,
139
+ read_directory,
139
140
  read_document,
140
141
  read_path,
141
142
  )
142
143
  from dirigent_cli.stream import Sink, track_steps, use_scratch_prefix
144
+ from dirigent_cli.summaries import credential_line
143
145
  from dirigent_cli.timing import RunProfile, attempt_timing, by_step, profile, step_timing
144
146
  from dirigent_client import (
145
147
  CLEAR,
@@ -315,15 +317,15 @@ def _print_plan(result: ApplyResult, origin: str) -> bool:
315
317
 
316
318
 
317
319
  def _print_source_tokens(result: ApplyResult, *, base_url: str) -> None:
318
- """Print each token an apply minted for a source, once, with where it is presented.
320
+ """Print each token or signing secret an apply minted for a source, once, with how it is presented.
319
321
 
320
- The instance stores only a token's hash; there is no second chance to read it.
322
+ There is no second chance to read either.
321
323
  """
322
324
  for minted in result.sources.tokens:
323
325
  console.print(f"\n source [bold]{minted.code}[/] {base_url.rstrip('/')}{minted.url_path}")
324
- console.print(f" Authorization: Bearer [bold]{minted.token}[/]")
326
+ console.print(f" {credential_line(minted.auth, minted.header, minted.token)}")
325
327
  if result.sources.tokens:
326
- console.print("\n[yellow]Each token is shown once.[/] The instance stores only its hash.\n")
328
+ console.print("\n[yellow]Each token and signing secret is shown once.[/]\n")
327
329
 
328
330
 
329
331
  def _print_graph(steps: Sequence[GraphStep], title: str = "steps") -> None:
@@ -374,9 +376,7 @@ def apply_command(
374
376
  as_code: Annotated[str | None, typer.Option("--as", help="Register under a different code.")] = None,
375
377
  paused: Annotated[
376
378
  bool,
377
- typer.Option(
378
- "--paused", help="Create the schedules and watches this apply mints paused; existing ones are untouched."
379
- ),
379
+ typer.Option("--paused", help="Create the schedules this apply creates paused; existing ones are untouched."),
380
380
  ] = False,
381
381
  prune: Annotated[
382
382
  bool,
@@ -495,7 +495,9 @@ def export_command(
495
495
 
496
496
  def validate_command(
497
497
  ctx: typer.Context,
498
- reference: Annotated[str | None, typer.Argument(help="A file, a URL, or - for standard input.")] = None,
498
+ reference: Annotated[
499
+ str | None, typer.Argument(help="A file, a directory of documents, a URL, or - for standard input.")
500
+ ] = None,
499
501
  server: Annotated[
500
502
  bool, typer.Option("--server", help="Also check it against a server's catalog and requirements.")
501
503
  ] = False,
@@ -512,12 +514,21 @@ def validate_command(
512
514
  pipelines it holds, the pipeline a triggers document names, and the parameters its
513
515
  schedules pin.
514
516
 
517
+ A directory is read at every depth, and each YAML or JSON file in it that declares a
518
+ `dirigent/` format is checked; the others are counted as skipped.
519
+
515
520
  `--explain` adds the shape of the work ahead for every pipeline document that is valid:
516
521
  the fan-out widths, the retry budgets and the waits. With `--server` too, a sensor that
517
522
  declares neither cadence nor deadline takes the block's own.
518
523
  """
519
524
  state = state_of(ctx)
520
- documents = _documents_to_apply(reference, None)
525
+ skipped = 0
526
+ if reference is not None and Path(reference).is_dir():
527
+ documents, skipped = read_directory(Path(reference))
528
+ if not documents:
529
+ fail(PROJECT_IS_EMPTY, directory=reference)
530
+ else:
531
+ documents = _documents_to_apply(reference, None)
521
532
  failures = 0
522
533
  parsed: list[tuple[PipelineDefinition | TriggersDefinition, str]] = []
523
534
  for document in documents:
@@ -563,6 +574,7 @@ def validate_command(
563
574
  blocks=blocks,
564
575
  format_checker=checker,
565
576
  target=targets.get(definition.pipeline) if isinstance(definition, TriggersDefinition) else None,
577
+ source_kinds=host.sources,
566
578
  )
567
579
  if issues:
568
580
  emit_fact(
@@ -601,6 +613,7 @@ def validate_command(
601
613
  message="invalid" if failures else "valid",
602
614
  documents=len(documents),
603
615
  invalid=failures,
616
+ skipped=skipped,
604
617
  )
605
618
  if failures:
606
619
  raise typer.Exit(code=1)
@@ -80,6 +80,7 @@ class ConnectionSpec(BaseModel):
80
80
  code: str
81
81
  name: str | None = None
82
82
  kind: str = "http"
83
+ description: str | None = None
83
84
  config: JsonMap = Field(default_factory=dict)
84
85
 
85
86
 
@@ -236,7 +237,16 @@ def connections_for(definitions: Sequence[PipelineDefinition], given: Sequence[C
236
237
  embedded: dict[str, ConnectionSpec] = {}
237
238
  for definition in definitions:
238
239
  for code, carried in definition.connections.items():
239
- embedded.setdefault(code, ConnectionSpec(code=code, kind=carried.kind, config=carried.config))
240
+ embedded.setdefault(
241
+ code,
242
+ ConnectionSpec(
243
+ code=code,
244
+ name=carried.name,
245
+ kind=carried.kind,
246
+ description=carried.description,
247
+ config=carried.config,
248
+ ),
249
+ )
240
250
  replaced = {spec.code for spec in given}
241
251
  return [spec for code, spec in embedded.items() if code not in replaced] + list(given)
242
252
 
@@ -349,6 +359,7 @@ async def _seed_connections(
349
359
  code=spec.code,
350
360
  name=spec.name,
351
361
  kind=spec.kind,
362
+ description=spec.description,
352
363
  config=public,
353
364
  secret_envelope=envelope,
354
365
  secret_key_id=key_id,
@@ -126,8 +126,6 @@ app.add_typer(commands.examples_app, rich_help_panel=DEFINE_PANEL)
126
126
  app.add_typer(commands.schema_app, rich_help_panel=DEFINE_PANEL)
127
127
  app.add_typer(commands.connection_app, rich_help_panel=CONNECT_PANEL)
128
128
  app.add_typer(triggers.schedule_app, rich_help_panel=TRIGGER_PANEL)
129
- app.add_typer(triggers.webhook_app, rich_help_panel=TRIGGER_PANEL)
130
- app.add_typer(triggers.watch_app, rich_help_panel=TRIGGER_PANEL)
131
129
  app.add_typer(source_commands.source_app, rich_help_panel=TRIGGER_PANEL)
132
130
  app.add_typer(triggers.trigger_document_app, rich_help_panel=TRIGGER_PANEL)
133
131
  app.add_typer(triggers.alerts_app, rich_help_panel=TRIGGER_PANEL)
@@ -627,7 +625,6 @@ def prune_command(
627
625
  ctx: typer.Context,
628
626
  runs: Annotated[str | None, typer.Option(help="Keep settled runs younger than this, such as 30d.")] = None,
629
627
  logs: Annotated[str | None, typer.Option(help="Keep log entries younger than this.")] = None,
630
- deliveries: Annotated[str | None, typer.Option(help="Keep webhook deliveries younger than this.")] = None,
631
628
  inputs: Annotated[str | None, typer.Option(help="Keep the record of inputs to a source younger than this.")] = None,
632
629
  firings: Annotated[str | None, typer.Option(help="Keep schedule firings younger than this.")] = None,
633
630
  notifications: Annotated[str | None, typer.Option(help="Keep sent alerts younger than this.")] = None,
@@ -647,7 +644,7 @@ def prune_command(
647
644
  settings = get_settings()
648
645
  state = state_of(ctx)
649
646
  try:
650
- policy = _prune_policy(settings, runs, logs, deliveries, inputs, firings, notifications, scratch=scratch)
647
+ policy = _prune_policy(settings, runs, logs, inputs, firings, notifications, scratch=scratch)
651
648
  except ValueError as error:
652
649
  refuse(INVALID_AGE, status=2, title="Invalid age", detail=str(error))
653
650
  raise typer.Exit(code=2) from error
@@ -658,7 +655,6 @@ def _prune_policy(
658
655
  settings: Settings,
659
656
  runs: str | None,
660
657
  logs: str | None,
661
- deliveries: str | None,
662
658
  inputs: str | None,
663
659
  firings: str | None,
664
660
  notifications: str | None,
@@ -675,7 +671,6 @@ def _prune_policy(
675
671
  return retention.Policy(
676
672
  runs=age(runs, settings.retention_runs),
677
673
  logs=age(logs, settings.retention_logs),
678
- deliveries=age(deliveries, settings.retention_deliveries),
679
674
  inputs=age(inputs, settings.retention_inputs),
680
675
  firings=age(firings, settings.retention_firings),
681
676
  notifications=age(notifications, settings.retention_notifications),
@@ -921,7 +916,7 @@ def dev(
921
916
  typer.Option(
922
917
  "--seed",
923
918
  help="Apply every dirigent/v1 document under this directory once the API answers, "
924
- "with its schedules and watches paused; name it more than once to seed one directory after another.",
919
+ "with its schedules paused; name it more than once to seed one directory after another.",
925
920
  ),
926
921
  ] = None,
927
922
  seed_installed: Annotated[
@@ -944,7 +939,7 @@ def dev(
944
939
 
945
940
  --seed fills the instance from a directory of documents the moment it answers: the
946
941
  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
942
+ first, then every document is applied with its schedules paused, and one refused
948
943
  for naming something a later document supplies is applied again once the rest are in. A
949
944
  document an instance will not store is reported and passed over, because a corpus holds
950
945
  those on purpose.
@@ -232,11 +232,6 @@ ONE_CLOCK = CLI.define("one_clock", "a schedule takes exactly one of --cron, --i
232
232
 
233
233
  AT_TAKES_AN_INSTANT = CLI.define("at_takes_an_instant", "--at takes an ISO instant, and {value} is not one")
234
234
 
235
- MAP_TAKES_A_PATH = CLI.define(
236
- "map_takes_a_path",
237
- "--map takes param=$.path.into.payload, and {entry} has no '='",
238
- )
239
-
240
235
  NOT_AN_ALERT_EVENT = CLI.define("not_an_alert_event", "{event} is not an alert event ({allowed})")
241
236
 
242
237
  NOT_AN_IMPORTANCE = CLI.define("not_an_importance", "--importance {value} is not an importance ({allowed})")
@@ -208,7 +208,9 @@ async def _connections(client: Dirigent, declared: Sequence[ConnectionSpec], ori
208
208
  async def _connection(client: Dirigent, spec: ConnectionSpec) -> str:
209
209
  """Store one connection and say whether it was new, letting a refusal through."""
210
210
  try:
211
- await client.connections.create(spec.code, kind=spec.kind, config=spec.config, name=spec.name)
211
+ await client.connections.create(
212
+ spec.code, kind=spec.kind, config=spec.config, name=spec.name, description=spec.description
213
+ )
212
214
  except DirigentError:
213
215
  await client.connections.update(spec.code, config=spec.config)
214
216
  return "updated"
@@ -216,7 +218,7 @@ async def _connection(client: Dirigent, spec: ConnectionSpec) -> str:
216
218
 
217
219
 
218
220
  async def _apply(client: Dirigent, raw: JsonMap, origin: str) -> Record:
219
- """Apply one document with its schedules and watches paused, without the sections it carries."""
221
+ """Apply one document with its schedules paused, without the sections it carries."""
220
222
  document = {name: value for name, value in raw.items() if name not in CARRIED}
221
223
  try:
222
224
  result = await client.pipelines.apply(
@@ -237,7 +239,6 @@ async def _apply(client: Dirigent, raw: JsonMap, origin: str) -> Record:
237
239
  pipeline=result.plan.code,
238
240
  action=result.plan.action.value,
239
241
  schedules_paused=result.triggers.schedules_created,
240
- watches_paused=result.triggers.watches_created,
241
242
  )
242
243
 
243
244
 
@@ -1,8 +1,11 @@
1
1
  """The source command group: where a pipeline's inputs enter, what has arrived, and calling one."""
2
2
 
3
+ import hashlib
4
+ import hmac
3
5
  import json
4
6
  from pathlib import Path
5
7
  from typing import Annotated, Final
8
+ from urllib.parse import urlencode
6
9
 
7
10
  import httpx2
8
11
  import typer
@@ -18,8 +21,11 @@ from dirigent_plugin.sources import Route
18
21
  #: Where ``dg source call`` reads the token a source was given when ``--source-token`` names none.
19
22
  TOKEN_ENV: Final = "DIRIGENT_SOURCE_TOKEN"
20
23
 
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)
24
+ #: Where ``dg source call`` and ``dg source set-secret`` read a signing secret when no option names one.
25
+ SECRET_ENV: Final = "DIRIGENT_SOURCE_SECRET"
26
+
27
+ #: How long a call waits for its answer, past the longest an instance holds a caller by default.
28
+ CALL_TIMEOUT: Final = httpx2.Timeout(90.0, connect=DEFAULT_CONNECT_TIMEOUT)
23
29
 
24
30
  source_app = typer.Typer(
25
31
  name="source",
@@ -80,10 +86,29 @@ def source_rotate(ctx: typer.Context, pipeline: PipelineArg, code: SourceArg) ->
80
86
  code=code,
81
87
  pipeline=pipeline,
82
88
  url=f"{base.rstrip('/')}{minted.url_path}",
89
+ auth=minted.auth,
90
+ header=minted.header,
83
91
  token=minted.token,
84
92
  )
85
93
 
86
94
 
95
+ @source_app.command("set-secret")
96
+ def source_set_secret(
97
+ ctx: typer.Context,
98
+ pipeline: PipelineArg,
99
+ code: SourceArg,
100
+ secret: Annotated[
101
+ str | None,
102
+ typer.Option("--secret", envvar=SECRET_ENV, help="The secret its sender issued; prompted for when unset."),
103
+ ] = None,
104
+ ) -> None:
105
+ """Replace a signed source's signing secret with one its sender issued, such as a provider's own."""
106
+ given = secret or typer.prompt("signing secret", hide_input=True)
107
+ with client_for(state_of(ctx)) as dg:
108
+ row = dg.call(dg.sources.set_secret(pipeline, code, given))
109
+ emit_fact("source.secret_set", message="secret set", code=code, pipeline=pipeline, prefix=row.token_prefix)
110
+
111
+
87
112
  @source_app.command("inputs")
88
113
  def source_inputs(
89
114
  ctx: typer.Context,
@@ -120,6 +145,10 @@ def source_call(
120
145
  str | None,
121
146
  typer.Option("--source-token", envvar=TOKEN_ENV, help="The source's own token, not the CLI's credential."),
122
147
  ] = None,
148
+ signing_secret: Annotated[
149
+ str | None,
150
+ typer.Option("--signing-secret", envvar=SECRET_ENV, help="The secret a signed source's body is signed with."),
151
+ ] = None,
123
152
  ) -> None:
124
153
  """Call a received source the way its callers do, and write down what it answered.
125
154
 
@@ -143,6 +172,12 @@ def source_call(
143
172
  elif text is not None:
144
173
  content = _read(text).encode()
145
174
  headers["Content-Type"] = "text/plain; charset=utf-8"
175
+ elif form is not None:
176
+ content = urlencode(_pairs(form)).encode()
177
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
178
+ if signing_secret and row.signature_header:
179
+ digest = hmac.new(signing_secret.encode(), content or b"", hashlib.sha256).hexdigest()
180
+ headers[row.signature_header] = f"sha256={digest}"
146
181
  try:
147
182
  with httpx2.Client(timeout=CALL_TIMEOUT) as http:
148
183
  answered = http.request(
@@ -151,7 +186,6 @@ def source_call(
151
186
  headers=headers,
152
187
  params=_pairs(query),
153
188
  content=content,
154
- data=_pairs(form) if form is not None else None,
155
189
  )
156
190
  except httpx2.HTTPError as error:
157
191
  fail(SOURCE_UNREACHABLE, url=url, detail=str(error))
@@ -8,6 +8,7 @@ import httpx2
8
8
  import yaml
9
9
  from pydantic import BaseModel, ConfigDict
10
10
 
11
+ from dirigent_cli.project import DOCUMENT_SUFFIXES
11
12
  from dirigent_client import DEFAULT_CONNECT_TIMEOUT
12
13
  from dirigent_client.enums import ProvenanceSource
13
14
 
@@ -71,6 +72,37 @@ def read_path(path: Path) -> Document:
71
72
  return Document(text=path.read_text(), source=ProvenanceSource.FILE, ref=str(path))
72
73
 
73
74
 
75
+ def read_directory(directory: Path) -> tuple[list[Document], int]:
76
+ """Read every document under a directory, at any depth, and count the files that are not one.
77
+
78
+ A YAML or JSON file is a document when it declares a ``dirigent/`` format, or when it does
79
+ not parse at all, so a broken document is reported rather than passed over.
80
+ """
81
+ documents: list[Document] = []
82
+ skipped = 0
83
+ for path in sorted(directory.rglob("*")):
84
+ if not path.is_file() or path.suffix not in DOCUMENT_SUFFIXES:
85
+ continue
86
+ document = read_path(path)
87
+ if _declares_a_format(document.text):
88
+ documents.append(document)
89
+ else:
90
+ skipped += 1
91
+ return documents, skipped
92
+
93
+
94
+ def _declares_a_format(text: str) -> bool:
95
+ """Whether a file names a dirigent format at its top, or cannot be read well enough to say."""
96
+ try:
97
+ parsed: object = yaml.safe_load(text)
98
+ except yaml.YAMLError:
99
+ return True
100
+ if not isinstance(parsed, dict):
101
+ return False
102
+ declared = cast("dict[str, Any]", parsed).get("format")
103
+ return isinstance(declared, str) and declared.startswith("dirigent/")
104
+
105
+
74
106
  def parse_document(document: Document) -> dict[str, Any]:
75
107
  """Parse a document's text, refusing what is plainly not a pipeline document, by name.
76
108
 
@@ -256,15 +256,24 @@ def _source_token(record: Record) -> RenderableType | None:
256
256
  token = record.get("token")
257
257
  if not token:
258
258
  return None
259
+ auth = str(record.get("auth") or "token")
260
+ header = record.get("header")
259
261
  return Group(
260
262
  "",
261
263
  f" [bold]{escape(str(record.get('url') or ''))}[/]",
262
- f" [bold]Authorization: Bearer {escape(str(token))}[/]",
264
+ f" [bold]{credential_line(auth, str(header) if header else None, escape(str(token)))}[/]",
263
265
  "",
264
- "[yellow]This is the only time the token is shown.[/] The instance stores only its hash.",
266
+ "[yellow]This is the only time it is shown.[/]",
265
267
  )
266
268
 
267
269
 
270
+ def credential_line(auth: str, header: str | None, secret: str) -> str:
271
+ """Say how a caller presents a source's credential: a bearer token, or a signature keyed with a secret."""
272
+ if auth == "hmac":
273
+ return f"{header}: sha256=<HMAC-SHA256 of the body, hex>, keyed with {secret}"
274
+ return f"Authorization: Bearer {secret}"
275
+
276
+
268
277
  def _files(record: Record) -> list[RenderableType]:
269
278
  """List what was written, named against the directory it was written into."""
270
279
  written = _texts(record, "files")
@@ -1,4 +1,4 @@
1
- """The trigger and alerting command groups: schedules, webhooks, watches, and alert rules."""
1
+ """The trigger and alerting command groups: schedules and alert rules."""
2
2
 
3
3
  from pathlib import Path
4
4
  from typing import Annotated, Any, cast
@@ -9,7 +9,6 @@ from dirigent_cli.commands import paged, parse_importance, parse_log_levels, par
9
9
  from dirigent_cli.context import Session, client_for, state_of
10
10
  from dirigent_cli.messages import (
11
11
  AT_TAKES_AN_INSTANT,
12
- MAP_TAKES_A_PATH,
13
12
  NOT_AN_ALERT_EVENT,
14
13
  ONE_BODY,
15
14
  ONE_CLOCK,
@@ -28,18 +27,12 @@ from dirigent_cli.output import (
28
27
  throttle_note,
29
28
  watching,
30
29
  )
31
- from dirigent_client import LOG_NOTIFIER, TEST_SUBJECT, AlertEvent, AlertScope, WebhookTokenOut
30
+ from dirigent_client import LOG_NOTIFIER, TEST_SUBJECT, AlertEvent, AlertScope
32
31
  from dirigent_common import Message
33
32
 
34
33
  schedule_app = typer.Typer(
35
34
  name="schedule", help="A pipeline's clocks: cron, interval, or one-time.", no_args_is_help=True
36
35
  )
37
- webhook_app = typer.Typer(name="webhook", help="Inbound webhooks and their delivery history.", no_args_is_help=True)
38
- watch_app = typer.Typer(
39
- name="watch",
40
- help="Sensors kept waiting: one run always waiting on each, the next armed on success.",
41
- no_args_is_help=True,
42
- )
43
36
  trigger_document_app = typer.Typer(
44
37
  name="trigger-document",
45
38
  help="Documents that declare clocks for a pipeline defined elsewhere.",
@@ -251,222 +244,6 @@ def schedule_delete(
251
244
  emit_fact("schedule.deleted", message="deleted", code=code, pipeline=pipeline)
252
245
 
253
246
 
254
- @watch_app.command("list")
255
- def watch_list(
256
- ctx: typer.Context,
257
- pipeline: Annotated[str, typer.Argument(help="The pipeline whose watches to list.")],
258
- ) -> None:
259
- """List a pipeline's watches, each with the run it has waiting and how its waits are going."""
260
- with client_for(state_of(ctx)) as dg:
261
- rows = list(paged(lambda after, size: dg.call(dg.watches.list(pipeline, after=after, limit=size)), None))
262
- emit_records("watch", rows)
263
-
264
-
265
- @watch_app.command("show")
266
- def watch_show(
267
- ctx: typer.Context,
268
- pipeline: Annotated[str, typer.Argument(help="The pipeline the watch belongs to.")],
269
- code: Annotated[str, typer.Argument(help="The watch to show.")],
270
- ) -> None:
271
- """Show one watch: the step it waits on, where it left off, and its last error."""
272
- with client_for(state_of(ctx)) as dg:
273
- row = dg.call(dg.watches.get(pipeline, code))
274
- emit_one("watch", row)
275
-
276
-
277
- @watch_app.command("pause")
278
- def watch_pause(
279
- ctx: typer.Context,
280
- pipeline: Annotated[str, typer.Argument()],
281
- code: Annotated[str, typer.Argument()],
282
- ) -> None:
283
- """Stop a watch, cancelling the run it has waiting, and keep where it left off."""
284
- with client_for(state_of(ctx)) as dg:
285
- row = dg.call(dg.watches.pause(pipeline, code))
286
- emit_fact("watch.paused", message="paused", code=code, pipeline=pipeline, cursor=row.cursor)
287
-
288
-
289
- @watch_app.command("resume")
290
- def watch_resume(
291
- ctx: typer.Context,
292
- pipeline: Annotated[str, typer.Argument()],
293
- code: Annotated[str, typer.Argument()],
294
- ) -> None:
295
- """Start a watch again, arming a run from where its last success left off."""
296
- with client_for(state_of(ctx)) as dg:
297
- row = dg.call(dg.watches.resume(pipeline, code))
298
- emit_fact(
299
- "watch.resumed",
300
- message="resumed",
301
- code=code,
302
- pipeline=pipeline,
303
- waiting_run_id=row.waiting_run_id,
304
- cursor=row.cursor,
305
- )
306
-
307
-
308
- def _print_token(minted: WebhookTokenOut, *, base_url: str) -> None:
309
- """Print a minted token once, with the URL already assembled.
310
-
311
- The instance stores only the token's hash; there is no second chance to read it.
312
- """
313
- console.print(f"\n POST [bold]{base_url.rstrip('/')}{minted.url_path}[/]")
314
- console.print(f" Token [bold]{minted.token}[/]")
315
- console.print("\n[yellow]This token is shown once.[/] The instance stores only its hash.\n")
316
-
317
-
318
- @webhook_app.command("create")
319
- def webhook_create(
320
- ctx: typer.Context,
321
- pipeline: Annotated[str, typer.Argument(help="The pipeline this webhook starts.")],
322
- code: Annotated[str, typer.Argument(help="What to call it; unique within the pipeline.")],
323
- name: Annotated[str | None, typer.Option("--name", help="A human title for this webhook.")] = None,
324
- description: Annotated[str | None, typer.Option("--description", help="What this webhook is for.")] = None,
325
- map_value: Annotated[
326
- list[str] | None,
327
- typer.Option("--map", help="param=$.path.into.payload, repeatable."),
328
- ] = None,
329
- hmac_secret: Annotated[
330
- str | None,
331
- typer.Option("--hmac-secret", help="Require an X-Dirigent-Signature over the raw body."),
332
- ] = None,
333
- rate_limit: Annotated[int, typer.Option("--rate-limit", help="Deliveries a minute this token may make.")] = 60,
334
- priority: Annotated[
335
- str | None,
336
- typer.Option(
337
- "--priority",
338
- help="How far ahead of other runs an accepted delivery's run is claimed: low, normal, or high. "
339
- "Omitted takes the pipeline's own.",
340
- ),
341
- ] = None,
342
- ) -> None:
343
- """Declare a webhook and print its token once, with the URL to POST to."""
344
- mapping: dict[str, str] = {}
345
- for entry in map_value or []:
346
- if "=" not in entry:
347
- _fail(MAP_TAKES_A_PATH, entry=repr(entry))
348
- key, path = entry.split("=", 1)
349
- mapping[key.strip()] = path.strip()
350
- with client_for(state_of(ctx)) as dg:
351
- minted = dg.call(
352
- dg.webhooks.create(
353
- pipeline,
354
- code,
355
- name=name,
356
- description=description,
357
- params_from_payload=mapping,
358
- hmac_secret=hmac_secret,
359
- rate_limit_per_minute=rate_limit,
360
- priority=parse_priority(priority),
361
- )
362
- )
363
- base = dg.url
364
- if state_of(ctx).json_output:
365
- return emit_fact(
366
- "webhook.created",
367
- message="created",
368
- code=minted.code,
369
- pipeline=pipeline,
370
- url=f"{base.rstrip('/')}{minted.url_path}",
371
- token=minted.token,
372
- )
373
- console.print(f"[green]created[/] webhook [bold]{minted.code}[/] on {pipeline}")
374
- _print_token(minted, base_url=base)
375
-
376
-
377
- @webhook_app.command("list")
378
- def webhook_list(
379
- ctx: typer.Context,
380
- pipeline: Annotated[str, typer.Argument(help="The pipeline whose webhooks to list.")],
381
- ) -> None:
382
- """List a pipeline's webhooks, with their mapping but never their tokens."""
383
- with client_for(state_of(ctx)) as dg:
384
- rows = list(paged(lambda after, size: dg.call(dg.webhooks.list(pipeline, after=after, limit=size)), None))
385
- if state_of(ctx).json_output:
386
- return emit_records("webhook", rows)
387
- table(
388
- f"webhooks of {pipeline}",
389
- ["code", "name", "token", "signed", "active", "limit/min", "maps", "last delivery"],
390
- [
391
- [
392
- row.code,
393
- row.name or "-",
394
- f"{row.token_prefix}...",
395
- render_bool(row.signed),
396
- render_bool(row.active),
397
- str(row.rate_limit_per_minute),
398
- ", ".join(sorted(row.params_from_payload)) or "-",
399
- moment(row.last_delivery_at),
400
- ]
401
- for row in rows
402
- ],
403
- )
404
-
405
-
406
- @webhook_app.command("rotate-token")
407
- def webhook_rotate(
408
- ctx: typer.Context,
409
- pipeline: Annotated[str, typer.Argument()],
410
- code: Annotated[str, typer.Argument()],
411
- ) -> None:
412
- """Mint a new token and forget the old one; every caller has to be updated."""
413
- with client_for(state_of(ctx)) as dg:
414
- minted = dg.call(dg.webhooks.rotate_token(pipeline, code))
415
- base = dg.url
416
- if state_of(ctx).json_output:
417
- return emit_fact(
418
- "webhook.token_rotated",
419
- message="rotated",
420
- code=code,
421
- pipeline=pipeline,
422
- url=f"{base.rstrip('/')}{minted.url_path}",
423
- token=minted.token,
424
- )
425
- console.print(f"[green]rotated[/] the token of webhook [bold]{code}[/]; the previous one no longer works")
426
- _print_token(minted, base_url=base)
427
-
428
-
429
- @webhook_app.command("deliveries")
430
- def webhook_deliveries(
431
- ctx: typer.Context,
432
- pipeline: Annotated[str, typer.Argument()],
433
- code: Annotated[str, typer.Argument()],
434
- ) -> None:
435
- """Show what has arrived at a webhook, refusals included."""
436
- with client_for(state_of(ctx)) as dg:
437
- rows = list(
438
- paged(lambda after, size: dg.call(dg.webhooks.deliveries(pipeline, code, after=after, limit=size)), None)
439
- )
440
- if state_of(ctx).json_output:
441
- return emit_records("delivery", rows)
442
- table(
443
- f"deliveries to {code}",
444
- ["received", "outcome", "run", "from", "detail"],
445
- [
446
- [
447
- moment(row.created_at),
448
- styled(row.outcome.value),
449
- str(row.run_id or "-")[:8],
450
- row.source or "-",
451
- row.reason or "-",
452
- ]
453
- for row in rows
454
- ],
455
- )
456
-
457
-
458
- @webhook_app.command("delete")
459
- def webhook_delete(
460
- ctx: typer.Context,
461
- pipeline: Annotated[str, typer.Argument()],
462
- code: Annotated[str, typer.Argument()],
463
- ) -> None:
464
- """Remove a webhook, its token, and its delivery history."""
465
- with client_for(state_of(ctx)) as dg:
466
- dg.call(dg.webhooks.delete(pipeline, code))
467
- emit_fact("webhook.deleted", message="deleted", code=code, pipeline=pipeline)
468
-
469
-
470
247
  @alerts_rules_app.command("list")
471
248
  def alerts_rules_list(
472
249
  ctx: typer.Context,
@@ -680,7 +457,7 @@ def trigger_document_show(
680
457
  ctx: typer.Context,
681
458
  code: Annotated[str, typer.Argument(help="The triggers document to read.")],
682
459
  ) -> None:
683
- """Show one triggers document and the schedules, webhooks and watches it owns."""
460
+ """Show one triggers document and the schedules it owns."""
684
461
  with client_for(state_of(ctx)) as dg:
685
462
  detail = dg.call(dg.trigger_documents.get(code))
686
463
  if state_of(ctx).json_output:
@@ -689,8 +466,6 @@ def trigger_document_show(
689
466
  console.print(f" pipeline [bold]{detail.pipeline}[/]")
690
467
  console.print(f" digest [dim]{detail.digest}[/]")
691
468
  console.print(f" schedules {', '.join(detail.schedules) or '-'}")
692
- console.print(f" webhooks {', '.join(detail.webhooks) or '-'}")
693
- console.print(f" watches {', '.join(detail.watches) or '-'}")
694
469
  if detail.description:
695
470
  console.print(f"\n{detail.description}")
696
471
 
@@ -700,17 +475,10 @@ def trigger_document_delete(
700
475
  ctx: typer.Context,
701
476
  code: Annotated[str, typer.Argument(help="The triggers document to remove.")],
702
477
  ) -> None:
703
- """Remove a triggers document and every schedule and webhook it declared."""
478
+ """Remove a triggers document and every schedule it declared."""
704
479
  with client_for(state_of(ctx)) as dg:
705
480
  dg.call(dg.trigger_documents.delete(code))
706
- emit_fact(
707
- "trigger_document.deleted",
708
- message="deleted",
709
- code=code,
710
- schedules="deleted",
711
- webhooks="deleted",
712
- watches="deleted",
713
- )
481
+ emit_fact("trigger_document.deleted", message="deleted", code=code, schedules="deleted")
714
482
 
715
483
 
716
484
  @alerts_app.command("retry")
File without changes
File without changes