dirigent-cli 0.20.0__tar.gz → 0.22.0__tar.gz

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