dirigent-cli 0.21.0__tar.gz → 0.23.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.21.0 → dirigent_cli-0.23.0}/PKG-INFO +9 -9
  2. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/pyproject.toml +9 -9
  3. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/pyproject.toml.orig +9 -9
  4. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/commands.py +99 -15
  5. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/context.py +8 -1
  6. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/explain.py +31 -11
  7. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/main.py +8 -3
  8. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/messages.py +15 -1
  9. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/output.py +44 -9
  10. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/profiles.py +27 -10
  11. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/schemas.py +4 -1
  12. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/seeding.py +13 -3
  13. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/sources.py +4 -1
  14. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/summaries.py +29 -0
  15. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/triggers.py +2 -2
  16. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/LICENSE +0 -0
  17. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/README.md +0 -0
  18. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/__init__.py +0 -0
  19. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/aliases.py +0 -0
  20. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/formatters.py +0 -0
  21. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/graph.py +0 -0
  22. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/health.py +0 -0
  23. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/init_form.py +0 -0
  24. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/local.py +0 -0
  25. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/params.py +0 -0
  26. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/project.py +0 -0
  27. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/py.typed +0 -0
  28. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/reaper.py +0 -0
  29. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/scaffold.py +0 -0
  30. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/starters.py +0 -0
  31. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/stream.py +0 -0
  32. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  33. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  34. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  35. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  36. {dirigent_cli-0.21.0 → dirigent_cli-0.23.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  37. {dirigent_cli-0.21.0 → dirigent_cli-0.23.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.21.0
3
+ Version: 0.23.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.21.0
10
- Requires-Dist: dirigent-blocks==0.21.0
11
- Requires-Dist: dirigent-client==0.21.0
12
- Requires-Dist: dirigent-common==0.21.0
13
- Requires-Dist: dirigent-core==0.21.0
14
- Requires-Dist: dirigent-examples==0.21.0
15
- Requires-Dist: dirigent-plugin==0.21.0
16
- Requires-Dist: dirigent-server==0.21.0
9
+ Requires-Dist: dirigent-block-execute==0.23.0
10
+ Requires-Dist: dirigent-blocks==0.23.0
11
+ Requires-Dist: dirigent-client==0.23.0
12
+ Requires-Dist: dirigent-common==0.23.0
13
+ Requires-Dist: dirigent-core==0.23.0
14
+ Requires-Dist: dirigent-examples==0.23.0
15
+ Requires-Dist: dirigent-plugin==0.23.0
16
+ Requires-Dist: dirigent-server==0.23.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.21.0"
3
+ version = "0.23.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.21.0",
15
- "dirigent-blocks==0.21.0",
16
- "dirigent-client==0.21.0",
17
- "dirigent-common==0.21.0",
18
- "dirigent-core==0.21.0",
19
- "dirigent-examples==0.21.0",
20
- "dirigent-plugin==0.21.0",
21
- "dirigent-server==0.21.0",
14
+ "dirigent-block-execute==0.23.0",
15
+ "dirigent-blocks==0.23.0",
16
+ "dirigent-client==0.23.0",
17
+ "dirigent-common==0.23.0",
18
+ "dirigent-core==0.23.0",
19
+ "dirigent-examples==0.23.0",
20
+ "dirigent-plugin==0.23.0",
21
+ "dirigent-server==0.23.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.21.0"
3
+ version = "0.23.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.21.0",
15
- "dirigent-blocks==0.21.0",
16
- "dirigent-client==0.21.0",
17
- "dirigent-common==0.21.0",
18
- "dirigent-core==0.21.0",
19
- "dirigent-examples==0.21.0",
20
- "dirigent-plugin==0.21.0",
21
- "dirigent-server==0.21.0",
14
+ "dirigent-block-execute==0.23.0",
15
+ "dirigent-blocks==0.23.0",
16
+ "dirigent-client==0.23.0",
17
+ "dirigent-common==0.23.0",
18
+ "dirigent-core==0.23.0",
19
+ "dirigent-examples==0.23.0",
20
+ "dirigent-plugin==0.23.0",
21
+ "dirigent-server==0.23.0",
22
22
  "httpx2>=2.12.0",
23
23
  "python-dotenv>=1.1.0",
24
24
  "pyyaml>=6.0.3",
@@ -74,6 +74,7 @@ from dirigent_cli.messages import (
74
74
  RUN_SKIPPED,
75
75
  SCAFFOLD_REFUSED,
76
76
  SCHEMA_NOT_AN_OBJECT,
77
+ SCHEMA_NOTHING_TO_CHANGE,
77
78
  SCHEMA_UNREADABLE,
78
79
  SET_DG_TOKEN,
79
80
  SOURCE_REFUSED,
@@ -86,8 +87,11 @@ from dirigent_cli.messages import (
86
87
  )
87
88
  from dirigent_cli.messages import WINDOW_GRAMMAR as WINDOW_GRAMMAR_MESSAGE
88
89
  from dirigent_cli.output import (
90
+ CONNECTION_HEALTH,
91
+ RUNS_IN_FLIGHT,
89
92
  Detail,
90
93
  age,
94
+ connection_health,
91
95
  console,
92
96
  elapsed,
93
97
  emit_event,
@@ -133,6 +137,7 @@ from dirigent_cli.sources import (
133
137
  from dirigent_cli.stream import Sink, track_steps, use_scratch_prefix
134
138
  from dirigent_cli.timing import RunProfile, attempt_timing, by_step, profile, step_timing
135
139
  from dirigent_client import (
140
+ CLEAR,
136
141
  ApplyResult,
137
142
  AttemptEvent,
138
143
  AttemptOut,
@@ -140,6 +145,7 @@ from dirigent_client import (
140
145
  BackfillAccepted,
141
146
  BlockKind,
142
147
  Catalog,
148
+ Clear,
143
149
  DocumentKind,
144
150
  ExampleDetail,
145
151
  ExampleOut,
@@ -157,6 +163,7 @@ from dirigent_client import (
157
163
  RunReport,
158
164
  RunStatus,
159
165
  ValidationIssue,
166
+ declared_label,
160
167
  )
161
168
  from dirigent_common import Issue, JsonMap, Message
162
169
  from dirigent_core import migrations
@@ -821,7 +828,7 @@ def pipeline_list(
821
828
  return emit_records("pipeline", rows)
822
829
  table(
823
830
  "pipelines",
824
- ["code", "name", "tags", "version", "active", "runs in flight", "updated"],
831
+ ["code", "name", "tags", "version", "active", RUNS_IN_FLIGHT, "updated"],
825
832
  [
826
833
  [
827
834
  row.code,
@@ -857,7 +864,7 @@ def pipeline_show(
857
864
  "importance": row.importance.value,
858
865
  "active": "yes" if row.active else "no",
859
866
  "current version": row.current_version,
860
- "runs in flight": row.active_runs,
867
+ RUNS_IN_FLIGHT: row.active_runs,
861
868
  "created": moment(row.created_at),
862
869
  },
863
870
  )
@@ -2435,7 +2442,7 @@ def connection_check(ctx: typer.Context, code: Annotated[str, typer.Argument()])
2435
2442
  report = dg.call(dg.connections.check(code))
2436
2443
  emit_fact(
2437
2444
  "connection.checked",
2438
- message="not verified" if report.healthy is None else "healthy" if report.healthy else "unhealthy",
2445
+ message=CONNECTION_HEALTH[connection_health(report.healthy)],
2439
2446
  code=code,
2440
2447
  healthy=report.healthy,
2441
2448
  detail=report.detail,
@@ -2463,8 +2470,11 @@ def schema_list(ctx: typer.Context) -> None:
2463
2470
  return emit_records("schema", rows)
2464
2471
  table(
2465
2472
  "schemas",
2466
- ["code", "name", "description"],
2467
- [[row.code, row.name or "-", row.description or "-"] for row in rows],
2473
+ ["code", "name", "description", "used by"],
2474
+ [
2475
+ [row.code, row.name or "-", row.description or "-", str(len(row.used_by)) if row.used_by else "-"]
2476
+ for row in rows
2477
+ ],
2468
2478
  )
2469
2479
 
2470
2480
 
@@ -2481,6 +2491,19 @@ def schema_create(
2481
2491
  ] = None,
2482
2492
  ) -> None:
2483
2493
  """Store a locally authored JSON Schema, taking its identity from its own keywords."""
2494
+ schema, document = _read_schema(reference)
2495
+ resolved = code
2496
+ if resolved is None and not (isinstance(schema.get("$id"), str) and code_from_id(cast("str", schema["$id"]))):
2497
+ resolved = code_from_id(Path(document.ref).name) if document.ref != "(stdin)" else None
2498
+ with client_for(state_of(ctx)) as dg:
2499
+ created = dg.call(dg.schemas.create(schema, code=resolved, name=name, description=description))
2500
+ if state_of(ctx).json_output:
2501
+ return emit_fact("schema.created", message="created", code=created.code, name=created.name)
2502
+ console.print(f"[green]stored[/] schema [bold]{created.code}[/]")
2503
+
2504
+
2505
+ def _read_schema(reference: str) -> tuple[JsonMap, Document]:
2506
+ """Read a locally authored JSON Schema from a file, a URL, or standard input."""
2484
2507
  try:
2485
2508
  document = read_document(reference)
2486
2509
  except SourceError as error:
@@ -2491,15 +2514,69 @@ def schema_create(
2491
2514
  fail(SCHEMA_UNREADABLE, label=document.label, detail=str(error))
2492
2515
  if not isinstance(body, dict):
2493
2516
  fail(SCHEMA_NOT_AN_OBJECT, label=document.label, kind=type(body).__name__)
2494
- schema = cast("JsonMap", body)
2495
- resolved = code
2496
- if resolved is None and not (isinstance(schema.get("$id"), str) and code_from_id(cast("str", schema["$id"]))):
2497
- resolved = code_from_id(Path(document.ref).name) if document.ref != "(stdin)" else None
2517
+ return cast("JsonMap", body), document
2518
+
2519
+
2520
+ def _label_given(text: str | None) -> str | Clear | None:
2521
+ """Read a label option: not given leaves what is stored, and given empty clears it.
2522
+
2523
+ An empty title is the absence of one rather than a title, and no screen can draw it, so
2524
+ the empty string is never what gets stored.
2525
+ """
2526
+ if text is None:
2527
+ return None
2528
+ return text.strip() or CLEAR
2529
+
2530
+
2531
+ @schema_app.command("update")
2532
+ def schema_update(
2533
+ ctx: typer.Context,
2534
+ code: Annotated[str, typer.Argument(help="The schema to change; a code is fixed once minted.")],
2535
+ reference: Annotated[
2536
+ str | None,
2537
+ typer.Argument(help="A JSON Schema file, or - for standard input, to replace the stored body with."),
2538
+ ] = None,
2539
+ name: Annotated[
2540
+ str | None,
2541
+ typer.Option(help="A human title; taken from the replacing schema's title when omitted, cleared when empty."),
2542
+ ] = None,
2543
+ description: Annotated[
2544
+ str | None,
2545
+ typer.Option(help="What the schema is for; taken from the replacing schema's description, cleared when empty."),
2546
+ ] = None,
2547
+ ) -> None:
2548
+ """Correct a stored schema: replace the shape, relabel it, or both.
2549
+
2550
+ What this was not given is left as it stands, and an option given empty clears what is
2551
+ stored. Replacing the body takes the new schema's own ``title`` and ``description`` where
2552
+ the options name neither, exactly as storing one does, so the labels belong to the shape
2553
+ the instance now holds rather than to the one it no longer does.
2554
+
2555
+ An edit is never refused for being depended on: a code is fixed once minted, so refusing
2556
+ it would freeze an in-use shape permanently. The answer names the pipelines that validate
2557
+ against it, and the decision was made for them.
2558
+ """
2559
+ schema = _read_schema(reference)[0] if reference is not None else None
2560
+ label = _label_given(name)
2561
+ about = _label_given(description)
2562
+ if schema is not None:
2563
+ label = declared_label(schema, "title") if label is None else label
2564
+ about = declared_label(schema, "description") if about is None else about
2565
+ changed = [
2566
+ field for field, given in (("name", label), ("description", about), ("body", schema)) if given is not None
2567
+ ]
2568
+ if not changed:
2569
+ fail(SCHEMA_NOTHING_TO_CHANGE, code=code)
2498
2570
  with client_for(state_of(ctx)) as dg:
2499
- created = dg.call(dg.schemas.create(schema, code=resolved, name=name, description=description))
2500
- if state_of(ctx).json_output:
2501
- return emit_fact("schema.created", message="created", code=created.code, name=created.name)
2502
- console.print(f"[green]stored[/] schema [bold]{created.code}[/]")
2571
+ edited = dg.call(dg.schemas.update(code, body=schema, name=label, description=about))
2572
+ emit_fact(
2573
+ "schema.updated",
2574
+ message="updated",
2575
+ code=edited.code,
2576
+ name=edited.name,
2577
+ changed=changed,
2578
+ used_by=edited.used_by,
2579
+ )
2503
2580
 
2504
2581
 
2505
2582
  @schema_app.command("show")
@@ -2509,13 +2586,20 @@ def schema_show(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> N
2509
2586
  row = dg.call(dg.schemas.get(code))
2510
2587
  if state_of(ctx).json_output:
2511
2588
  return emit_one("schema", row)
2512
- fields(f"schema {code}", {"name": row.name or "-", "description": row.description or "-"})
2589
+ fields(
2590
+ f"schema {code}",
2591
+ {
2592
+ "name": row.name or "-",
2593
+ "description": row.description or "-",
2594
+ "used by": ", ".join(row.used_by) or "-",
2595
+ },
2596
+ )
2513
2597
  console.print_json(data=row.body)
2514
2598
 
2515
2599
 
2516
2600
  @schema_app.command("delete")
2517
2601
  def schema_delete(ctx: typer.Context, code: Annotated[str, typer.Argument()]) -> None:
2518
- """Remove a schema."""
2602
+ """Remove a schema, which the instance refuses while a stored pipeline names it."""
2519
2603
  with client_for(state_of(ctx)) as dg:
2520
2604
  dg.call(dg.schemas.delete(code))
2521
2605
  emit_fact("schema.deleted", message="deleted", code=code)
@@ -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(
@@ -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
@@ -402,7 +402,7 @@ async def _ensure_container(settings: Settings) -> "ContainerResult":
402
402
  except DomainError as error:
403
403
  commands.fail(STORE_UNCONFIGURED, root=settings.artifact_root, detail=str(error))
404
404
  except Exception as error:
405
- 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))
406
406
  finally:
407
407
  await engine.dispose()
408
408
 
@@ -443,7 +443,12 @@ def connection_ensure(
443
443
  except ValidationError as error:
444
444
  # include_input=False: the input here is a credential, and pydantic's default error
445
445
  # payload echoes the value that failed.
446
- 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
+ )
447
452
  key = settings.secret_key.get_secret_value() if settings.secret_key else None
448
453
  try:
449
454
  public, envelope, key_id = SecretBox(key).encrypt_config(model, validated)
@@ -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,20 @@ 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
+ SCHEMA_NOTHING_TO_CHANGE = CLI.define(
193
+ "schema_nothing_to_change",
194
+ "nothing was given to change about {code}; pass a schema file, --name or --description",
195
+ )
196
+
197
+ # What a profile refuses at validation. Pydantic owns the code a validator's refusal reaches
198
+ # the wire under, so this is rendered into the ``ValueError`` it wraps.
199
+
200
+ PROFILE_NAMES_A_DATABASE = CLI.define(
201
+ "profile_names_a_database",
202
+ "profile {name} names a database URL. Profiles address a server over HTTP; "
203
+ "a database URL belongs in DIRIGENT_DATABASE_URL on the host that runs it",
204
+ )
205
+
192
206
  NOT_AUTHENTICATED = CLI.define("not_authenticated", "no token for {url}")
193
207
 
194
208
  SET_DG_TOKEN = CLI.define("set_dg_token", "set DG_TOKEN")
@@ -474,16 +474,29 @@ 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
+ #: What a pipeline's unsettled runs are called, wherever a rendering names the count the server
482
+ #: computes from `ACTIVE_RUN_STATUSES`. The web UI heads its own feed of them with the same noun
483
+ #: out of its catalogue, and `tests/test_shared_words.py` fails when the two stop agreeing.
484
+ RUNS_IN_FLIGHT = "runs in flight"
485
+
486
+
477
487
  def watching(scope: object, importance: object) -> str:
478
488
  """What an alert rule watches, in one cell: its scope, and the floor it fires at.
479
489
 
480
490
  Almost every rule names no importance, so a column of its own would mostly be empty.
481
491
  """
482
- return f"{scope}, {importance} and above" if importance else str(scope)
492
+ if not importance:
493
+ return str(scope)
494
+ return f"{scope}, {IMPORTANCE_FLOOR.format(importance=importance)}"
483
495
 
484
496
 
485
497
  #: 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.
498
+ #: with; these are the words a reader is shown. The web UI draws the same four out of its own
499
+ #: catalogue, and `tests/test_shared_words.py` fails when the two tables stop agreeing.
487
500
  ALERT_EVENTS: Mapping[str, str] = {
488
501
  "run_failed": "Failed",
489
502
  "run_completed_with_errors": "Completed with errors",
@@ -529,17 +542,39 @@ def render_bool(value: object) -> str:
529
542
  return "[green]yes[/]" if value else "[dim]no[/]"
530
543
 
531
544
 
545
+ #: What a connection's last check is called, keyed by the state the web UI keys it by. A
546
+ #: credential's health is one vocabulary across the product, so these are the bundle's own four
547
+ #: words and `tests/test_shared_words.py` fails when the two tables stop agreeing.
548
+ CONNECTION_HEALTH: Mapping[str, str] = {
549
+ "unchecked": "never checked",
550
+ "unverified": "not verified",
551
+ "healthy": "healthy",
552
+ "failed": "failed",
553
+ }
554
+
555
+
556
+ def connection_health(healthy: object) -> str:
557
+ """Say which state a check that ran left a connection in.
558
+
559
+ Args:
560
+ healthy: What the check decided, or None where the kind publishes no check.
561
+
562
+ Returns:
563
+ The key into `CONNECTION_HEALTH`.
564
+ """
565
+ if healthy is None:
566
+ return "unverified"
567
+ return "healthy" if healthy else "failed"
568
+
569
+
532
570
  def render_check(last_check_at: object, healthy: object) -> str:
533
571
  """Render what a connection's last check said, across the four states a row can be in.
534
572
 
535
- A check answers yes, no, or that it could not decide; a row nothing has checked answers
536
- none of the three.
573
+ A check answers that the system is there, that it is not, or that the kind publishes no
574
+ check at all; a row nothing has ever asked is in none of the three.
537
575
  """
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[/]"
576
+ state = connection_health(healthy) if last_check_at else "unchecked"
577
+ return f"[{STATUS_STYLES.get(state, 'dim')}]{CONNECTION_HEALTH[state]}[/]"
543
578
 
544
579
 
545
580
  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
 
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  from typing import Any, cast
12
12
 
13
13
  from dirigent_cli.local import ConnectionSpec
14
- from dirigent_client import Dirigent, DirigentError, PlanAction, ProvenanceSource
14
+ from dirigent_client import Dirigent, DirigentError, PlanAction, ProvenanceSource, declared_label
15
15
  from dirigent_common import JsonMap
16
16
  from dirigent_core.documents import CARRIED, SUFFIXES, is_document, readable, safe_load
17
17
  from dirigent_core.protocol import Record, make
@@ -99,11 +99,21 @@ def _schemas(raw: JsonMap) -> dict[str, JsonMap]:
99
99
 
100
100
 
101
101
  async def _schema(client: Dirigent, code: str, body: JsonMap) -> None:
102
- """Store one named schema, replacing the body of one this instance already holds."""
102
+ """Store one named schema, replacing the shape and the labels of one already held.
103
+
104
+ Storing reads the schema's own ``title`` and ``description`` and editing reads neither, so
105
+ the replacement sends what the document's schema declares about itself; otherwise the same
106
+ corpus would label a schema one way on a fresh instance and another way on a seeded one.
107
+ """
103
108
  try:
104
109
  await client.schemas.create(body, code=code)
105
110
  except DirigentError:
106
- await client.schemas.update(code, body=body)
111
+ await client.schemas.update(
112
+ code,
113
+ body=body,
114
+ name=declared_label(body, "title"),
115
+ description=declared_label(body, "description"),
116
+ )
107
117
 
108
118
 
109
119
  async def _connections(client: Dirigent, declared: Sequence[ConnectionSpec], origin: str) -> AsyncIterator[Record]:
@@ -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):
@@ -52,6 +52,7 @@ BULKY: Final = frozenset(
52
52
  "token",
53
53
  "preflight",
54
54
  "requires",
55
+ "used_by",
55
56
  }
56
57
  )
57
58
 
@@ -342,6 +343,33 @@ def _created(record: Record) -> RenderableType | None:
342
343
  return Group(*parts)
343
344
 
344
345
 
346
+ #: What a changed shape reaches. The web UI warns in these words before a save, out of its own
347
+ #: catalogue, and `tests/test_shared_words.py` fails when the two stop agreeing. Written without
348
+ #: terminal punctuation, because the rendering puts the pipelines after it.
349
+ SCHEMA_REACHES = "Every pipeline naming this code checks against this shape from its next run"
350
+
351
+ #: What it does not reach. Said after the pipelines rather than with them: the colon above governs
352
+ #: that list, and a run in flight is not on it. Paired with the web UI's own words by the
353
+ #: same test, and punctuated, because nothing follows it.
354
+ IN_FLIGHT_KEEPS_ITS_SHAPE = "A run in flight keeps the shape it started with."
355
+
356
+
357
+ def _schema_edited(record: Record) -> RenderableType | None:
358
+ """Render whose pipelines the shape that was just changed was changed for.
359
+
360
+ An edit is never refused for being depended on, so the pipelines checking against the
361
+ code are the ones the edit was a decision about, and they are named rather than counted.
362
+ Where no pipeline names the code, no run checks against it either, so neither line is drawn.
363
+ """
364
+ named = _texts(record, "used_by")
365
+ if not named:
366
+ return None
367
+ parts: list[RenderableType] = [f"\n{SCHEMA_REACHES}:"]
368
+ parts.extend(f" [yellow]-[/] {escape(one)}" for one in named)
369
+ parts.append(f"\n{IN_FLIGHT_KEEPS_ITS_SHAPE}")
370
+ return Group(*parts)
371
+
372
+
345
373
  def _config(record: Record) -> RenderableType | None:
346
374
  """Render the effective configuration as a setting-per-row table."""
347
375
  settings = record.get("settings")
@@ -524,4 +552,5 @@ RENDERERS: Final[Mapping[str, Callable[[Record], RenderableType | None]]] = {
524
552
  "project.scaffolded": _scaffolded,
525
553
  "instance.initialised": _initialised,
526
554
  "pipeline.created": _created,
555
+ "schema.updated": _schema_edited,
527
556
  }
@@ -28,7 +28,7 @@ from dirigent_cli.output import (
28
28
  throttle_note,
29
29
  watching,
30
30
  )
31
- from dirigent_client import LOG_NOTIFIER, AlertEvent, AlertScope, WebhookTokenOut
31
+ from dirigent_client import LOG_NOTIFIER, TEST_SUBJECT, AlertEvent, AlertScope, WebhookTokenOut
32
32
  from dirigent_common import Message
33
33
 
34
34
  schedule_app = typer.Typer(
@@ -609,7 +609,7 @@ def alerts_test(
609
609
  str | None,
610
610
  typer.Option("--connection", help="The connection to deliver through; none delivers to the process log."),
611
611
  ] = None,
612
- subject: Annotated[str, typer.Option("--subject", help="What the test message says.")] = "dirigent test alert",
612
+ subject: Annotated[str, typer.Option("--subject", help="What the test message says.")] = TEST_SUBJECT,
613
613
  ) -> None:
614
614
  """Send a test message to one target, on the same queue a real alert takes."""
615
615
  with client_for(state_of(ctx)) as dg:
File without changes
File without changes