dirigent-cli 0.14.1__tar.gz → 0.15.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 (35) hide show
  1. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/PKG-INFO +2 -1
  2. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/pyproject.toml +5 -1
  3. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/pyproject.toml.orig +3 -1
  4. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/commands.py +171 -2
  5. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/init_form.py +35 -5
  6. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/main.py +32 -8
  7. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/project.py +42 -210
  8. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/seeding.py +40 -31
  9. dirigent_cli-0.15.0/src/dirigent_cli/starters.py +121 -0
  10. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/summaries.py +34 -3
  11. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/LICENSE +0 -0
  12. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/README.md +0 -0
  13. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/__init__.py +0 -0
  14. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/aliases.py +0 -0
  15. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/context.py +0 -0
  16. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/formatters.py +0 -0
  17. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/graph.py +0 -0
  18. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/health.py +0 -0
  19. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/local.py +0 -0
  20. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/output.py +0 -0
  21. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/params.py +0 -0
  22. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/profiles.py +0 -0
  23. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/py.typed +0 -0
  24. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/reaper.py +0 -0
  25. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/scaffold.py +0 -0
  26. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/schemas.py +0 -0
  27. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/sources.py +0 -0
  28. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/stream.py +0 -0
  29. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  30. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  31. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  32. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  33. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  34. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/timing.py +0 -0
  35. {dirigent_cli-0.14.1 → dirigent_cli-0.15.0}/src/dirigent_cli/triggers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.14.1
3
+ Version: 0.15.0
4
4
  Summary: The dirigent command line interface (dirigent / dg).
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -8,6 +8,7 @@ Requires-Dist: dirigent-blocks
8
8
  Requires-Dist: dirigent-client
9
9
  Requires-Dist: dirigent-common
10
10
  Requires-Dist: dirigent-core
11
+ Requires-Dist: dirigent-examples
11
12
  Requires-Dist: dirigent-plugin
12
13
  Requires-Dist: dirigent-server
13
14
  Requires-Dist: httpx2>=2.12.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.14.1"
3
+ version = "0.15.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,6 +11,7 @@ dependencies = [
11
11
  "dirigent-client",
12
12
  "dirigent-common",
13
13
  "dirigent-core",
14
+ "dirigent-examples",
14
15
  "dirigent-plugin",
15
16
  "dirigent-server",
16
17
  "httpx2>=2.12.0",
@@ -40,6 +41,9 @@ workspace = true
40
41
  [tool.uv.sources.dirigent-core]
41
42
  workspace = true
42
43
 
44
+ [tool.uv.sources.dirigent-examples]
45
+ workspace = true
46
+
43
47
  [tool.uv.sources.dirigent-plugin]
44
48
  workspace = true
45
49
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.14.1"
3
+ version = "0.15.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -11,6 +11,7 @@ dependencies = [
11
11
  "dirigent-client",
12
12
  "dirigent-common",
13
13
  "dirigent-core",
14
+ "dirigent-examples",
14
15
  "dirigent-plugin",
15
16
  "dirigent-server",
16
17
  "httpx2>=2.12.0",
@@ -35,5 +36,6 @@ build-backend = "uv_build"
35
36
  dirigent-blocks = { workspace = true }
36
37
  dirigent-client = { workspace = true }
37
38
  dirigent-core = { workspace = true }
39
+ dirigent-examples = { workspace = true }
38
40
  dirigent-plugin = { workspace = true }
39
41
  dirigent-server = { workspace = true }
@@ -13,7 +13,7 @@ import typer
13
13
  import yaml
14
14
  from rich.markup import escape
15
15
 
16
- from dirigent_cli import schemas
16
+ from dirigent_cli import schemas, starters
17
17
  from dirigent_cli.context import CliState, Session, client_for, state_of
18
18
  from dirigent_cli.graph import GraphStep, render_graph, steps_of_document
19
19
  from dirigent_cli.local import (
@@ -56,6 +56,7 @@ from dirigent_cli.output import (
56
56
  )
57
57
  from dirigent_cli.params import ParamError, build_params
58
58
  from dirigent_cli.project import (
59
+ DEFAULT_PIPELINES_DIR,
59
60
  InitChoices,
60
61
  ProjectError,
61
62
  check_choices,
@@ -76,6 +77,8 @@ from dirigent_client import (
76
77
  BlockKind,
77
78
  Catalog,
78
79
  DocumentKind,
80
+ ExampleDetail,
81
+ ExampleOut,
79
82
  ItemOut,
80
83
  LogEntryOut,
81
84
  LogLevel,
@@ -103,6 +106,7 @@ from dirigent_core.documents import (
103
106
  from dirigent_core.engine.definition import PipelineDefinition, TriggersDefinition, load_definition
104
107
  from dirigent_core.engine.runs import RunWindow
105
108
  from dirigent_core.engine.state import in_execution_order
109
+ from dirigent_core.examples import STARTER_TAG, ExampleEntry
106
110
  from dirigent_core.schemas import code_from_id
107
111
 
108
112
  RUNS_PAGE = 50
@@ -121,6 +125,7 @@ connection_app = typer.Typer(
121
125
  )
122
126
  schema_app = typer.Typer(name="schema", help="Named JSON Schemas the instance holds.", no_args_is_help=True)
123
127
  blocks_app = typer.Typer(name="blocks", help="The block catalog every plugin contributes to.", no_args_is_help=True)
128
+ examples_app = typer.Typer(name="examples", help="The documents every installed plugin ships.", no_args_is_help=True)
124
129
  token_app = typer.Typer(name="token", help="API tokens.", no_args_is_help=True)
125
130
  user_app = typer.Typer(name="user", help="Local accounts.", no_args_is_help=True)
126
131
  auth_app = typer.Typer(name="auth", help="Logging in, and which identity the CLI holds.", no_args_is_help=True)
@@ -492,6 +497,13 @@ def init_command(
492
497
  pack: Annotated[
493
498
  list[str] | None, typer.Option("--pack", help="A pack to add, such as dirigent-dhis2. Repeatable.")
494
499
  ] = None,
500
+ pipeline: Annotated[
501
+ list[str] | None,
502
+ typer.Option(
503
+ "--pipeline",
504
+ help="A starter to copy into pipelines/, as `dg examples list --starter` names it. Repeatable.",
505
+ ),
506
+ ] = None,
495
507
  admin: Annotated[str, typer.Option(help="The first admin account's username.")] = "admin",
496
508
  password: Annotated[str | None, typer.Option(help="Its password; asked for when omitted.")] = None,
497
509
  ) -> None:
@@ -511,7 +523,7 @@ def init_command(
511
523
 
512
524
  root = directory.resolve()
513
525
  version = cli_version()
514
- unasked = template is None and service is None and pack is None and not workflow
526
+ unasked = template is None and service is None and pack is None and pipeline is None and not workflow
515
527
  asked = unasked and sys.stdin.isatty() and sys.stdout.isatty()
516
528
  # Refusing happens before anything is written or asked for, so a run that cannot finish
517
529
  # has not half-created a project, and nobody fills a form for a command that was going
@@ -533,6 +545,7 @@ def init_command(
533
545
  services=tuple(service) if service is not None else InitChoices().services,
534
546
  workflow=workflow,
535
547
  packs=tuple(pack or ()),
548
+ pipelines=tuple(pipeline or ()),
536
549
  admin=admin,
537
550
  password=password or os.environ.get(BOOTSTRAP_PASSWORD_ENV) or "",
538
551
  )
@@ -557,6 +570,7 @@ def init_command(
557
570
  **({"services": list(choices.services)} if choices.stack else {}),
558
571
  **({"workflow": True} if choices.workflow else {}),
559
572
  **({"packs": list(choices.packs)} if choices.packs else {}),
573
+ **({"pipelines": list(choices.pipelines)} if choices.pipelines else {}),
560
574
  }
561
575
  if not choices.instance:
562
576
  starting = [
@@ -2598,3 +2612,158 @@ def auth_status(ctx: typer.Context) -> None:
2598
2612
  role=me.role.value,
2599
2613
  via=me.via.value if me.via else "-",
2600
2614
  )
2615
+
2616
+
2617
+ def installed_examples() -> list[ExampleDetail]:
2618
+ """Read the corpus installed beside this `dg`, without addressing any server."""
2619
+ from dirigent_core.plugins import load_plugin_host
2620
+
2621
+ return [as_detail(entry) for entry in load_plugin_host().examples()]
2622
+
2623
+
2624
+ def as_detail(entry: ExampleEntry) -> ExampleDetail:
2625
+ """Render one entry of the installed catalogue the way the API renders one."""
2626
+ return ExampleDetail(**entry.model_dump())
2627
+
2628
+
2629
+ def _example_rows(
2630
+ state: CliState,
2631
+ *,
2632
+ local: bool,
2633
+ tags: Sequence[str] = (),
2634
+ shelf: str | None = None,
2635
+ plugin: str | None = None,
2636
+ starter: bool | None = None,
2637
+ ) -> list[ExampleOut]:
2638
+ """List the catalogue, from the installed corpus or from the instance's own."""
2639
+ if local:
2640
+ wanted = set(tags)
2641
+ return [
2642
+ row
2643
+ for row in installed_examples()
2644
+ if wanted <= set(row.tags)
2645
+ and (shelf is None or row.shelf == shelf)
2646
+ and (plugin is None or row.plugin == plugin)
2647
+ and (starter is None or row.starter is starter)
2648
+ ]
2649
+ with client_for(state) as dg:
2650
+ return list(
2651
+ paged(
2652
+ lambda after, size: dg.call(
2653
+ dg.examples.list(tags=tags, shelf=shelf, plugin=plugin, starter=starter, after=after, limit=size)
2654
+ ),
2655
+ None,
2656
+ )
2657
+ )
2658
+
2659
+
2660
+ def _example(state: CliState, code: str, *, local: bool) -> ExampleDetail:
2661
+ """Resolve one example by code, from the installed corpus or from the instance's own."""
2662
+ if not local:
2663
+ with client_for(state) as dg:
2664
+ return dg.call(dg.examples.get(code))
2665
+ from dirigent_core.plugins import UnknownExample, load_plugin_host
2666
+
2667
+ try:
2668
+ return as_detail(load_plugin_host().example(code))
2669
+ except UnknownExample as error:
2670
+ refuse(str(error), title="No such example")
2671
+ raise typer.Exit(code=1) from error
2672
+
2673
+
2674
+ LOCAL_CATALOGUE = "Read the corpus installed beside dg, instead of the instance's own."
2675
+
2676
+
2677
+ @examples_app.command("list")
2678
+ def examples_list(
2679
+ ctx: typer.Context,
2680
+ tag: Annotated[
2681
+ list[str] | None, typer.Option("--tag", help="Only documents wearing this tag; repeat it to name more.")
2682
+ ] = None,
2683
+ shelf: Annotated[str | None, typer.Option("--shelf", help="Only documents on this shelf.")] = None,
2684
+ plugin: Annotated[str | None, typer.Option("--plugin", help="Only documents this distribution ships.")] = None,
2685
+ starter: Annotated[bool, typer.Option("--starter", help="Only the documents dg pipeline new may copy.")] = False,
2686
+ local: Annotated[bool, typer.Option("--local", help=LOCAL_CATALOGUE)] = False,
2687
+ ) -> None:
2688
+ """List the documents every installed plugin ships, and which of them are starters."""
2689
+ state = state_of(ctx)
2690
+ rows = _example_rows(
2691
+ state, local=local, tags=tag or [], shelf=shelf, plugin=plugin, starter=True if starter else None
2692
+ )
2693
+ if state.json_output:
2694
+ return emit_records("example", rows)
2695
+ table(
2696
+ "examples",
2697
+ ["code", "name", "tags", "plugin", "starter", "needs"],
2698
+ [
2699
+ [
2700
+ row.code,
2701
+ row.name or "-",
2702
+ " ".join(one for one in row.tags if one != STARTER_TAG) or "-",
2703
+ row.plugin,
2704
+ "[green]*[/]" if row.starter else "",
2705
+ starters.summary(row.requires),
2706
+ ]
2707
+ for row in rows
2708
+ ],
2709
+ )
2710
+
2711
+
2712
+ @examples_app.command("show")
2713
+ def examples_show(
2714
+ ctx: typer.Context,
2715
+ code: Annotated[str, typer.Argument(help="The example to read.")],
2716
+ local: Annotated[bool, typer.Option("--local", help=LOCAL_CATALOGUE)] = False,
2717
+ ) -> None:
2718
+ """Print one example's document, verbatim, as the shelf holds it."""
2719
+ state = state_of(ctx)
2720
+ entry = _example(state, code, local=local)
2721
+ if state.json_output:
2722
+ return emit_fact("example.source", message="example", **entry.model_dump(mode="json"))
2723
+ console.print(entry.source, end="", highlight=False, markup=False)
2724
+
2725
+
2726
+ @pipeline_app.command("new")
2727
+ def pipeline_new(
2728
+ ctx: typer.Context,
2729
+ starter: Annotated[str, typer.Argument(help="The starter to copy, as `dg examples list --starter` names it.")],
2730
+ code: Annotated[
2731
+ str | None, typer.Option("--code", help="Register the copy under this code; the starter's own when omitted.")
2732
+ ] = None,
2733
+ directory: Annotated[Path, typer.Option("--dir", help="Where to write it; pipelines/ when omitted.")] = Path(
2734
+ DEFAULT_PIPELINES_DIR
2735
+ ),
2736
+ local: Annotated[bool, typer.Option("--local", help=LOCAL_CATALOGUE)] = False,
2737
+ ) -> None:
2738
+ """Copy a starter into this project as a pipeline of its own.
2739
+
2740
+ The copy is the starter's text verbatim, with the top-level `code:` rewritten and the
2741
+ `starter` tag dropped, so every teaching comment in it survives. What it needs from the
2742
+ instance is the copy's own `requires`, which this prints as the list to work through.
2743
+ """
2744
+ entry = _example(state_of(ctx), starter, local=local)
2745
+ if not entry.starter:
2746
+ refuse(
2747
+ f"{entry.code!r} is an example, not a starter",
2748
+ title="Not a starter",
2749
+ problems=[f"a document is copyable only when it wears the {STARTER_TAG!r} tag"],
2750
+ )
2751
+ raise typer.Exit(code=1)
2752
+ new_code = code or entry.code
2753
+ path = directory / f"{new_code}.yaml"
2754
+ if path.exists():
2755
+ refuse(f"{path} is already there", title="Already there", problems=["name another code with --code"])
2756
+ raise typer.Exit(code=1)
2757
+ text = starters.instantiate(entry.source, new_code)
2758
+ directory.mkdir(parents=True, exist_ok=True)
2759
+ path.write_text(text)
2760
+ copied = load_pipeline_text(text).requires
2761
+ emit_fact(
2762
+ "pipeline.created",
2763
+ message="created",
2764
+ path=str(path),
2765
+ code=new_code,
2766
+ starter=entry.code,
2767
+ requires=copied.model_dump(mode="json"),
2768
+ preflight=starters.preflight(copied),
2769
+ )
@@ -9,7 +9,7 @@ Nothing here touches the disk.
9
9
  from __future__ import annotations
10
10
 
11
11
  from pathlib import Path
12
- from typing import cast
12
+ from typing import TYPE_CHECKING, cast
13
13
 
14
14
  from textual.app import App, ComposeResult
15
15
  from textual.binding import Binding
@@ -20,11 +20,14 @@ from textual.widgets import Button, Checkbox, Input, Label, RadioButton, RadioSe
20
20
  from dirigent_cli.project import (
21
21
  DEFAULT_SERVICES,
22
22
  PACKS,
23
- SERVICE_EXAMPLES,
24
23
  SERVICES,
25
24
  InitChoices,
25
+ installed_starters,
26
26
  )
27
27
 
28
+ if TYPE_CHECKING:
29
+ from dirigent_core.examples import ExampleEntry
30
+
28
31
  MIN_PASSWORD_LENGTH = 8
29
32
 
30
33
  #: The web UI's dark palette, so the form is the same product as the screen behind the door:
@@ -63,6 +66,24 @@ KINDS = (
63
66
  )
64
67
 
65
68
 
69
+ #: How much of a starter's description fits on the line that offers it.
70
+ DESCRIPTION_WIDTH = 60
71
+
72
+
73
+ def starter_label(entry: ExampleEntry) -> str:
74
+ """Name one starter the way every screen names an addressable thing.
75
+
76
+ The title is the name where there is one and the code otherwise, the code is on the line
77
+ exactly once, and the description is the rest of it.
78
+ """
79
+ described = (entry.description or "").strip().splitlines()
80
+ first = described[0] if described else ""
81
+ if len(first) > DESCRIPTION_WIDTH:
82
+ first = first[: DESCRIPTION_WIDTH - 1].rstrip() + "\u2026"
83
+ trailing = " ".join(part for part in ([entry.code] if entry.name else []) + ([first] if first else []))
84
+ return f"{entry.name or entry.code} [dim]{trailing}[/]" if trailing else entry.name or entry.code
85
+
86
+
66
87
  class InitForm(App[InitChoices | None]):
67
88
  """The scaffolding form: kind, services, packs, workflow, admin, on one screen."""
68
89
 
@@ -81,7 +102,7 @@ class InitForm(App[InitChoices | None]):
81
102
  Checkbox > .toggle--button { color: $surface; background: $surface; }
82
103
  Checkbox.-on > .toggle--button { color: $success; background: $surface; }
83
104
  #kind { height: auto; }
84
- #services, #packs { height: auto; border: round $border; }
105
+ #services, #packs, #starters { height: auto; max-height: 12; border: round $border; }
85
106
  #admin-block { height: auto; }
86
107
  #admin-row { height: auto; }
87
108
  #admin-row Input { width: 1fr; margin-right: 2; }
@@ -105,6 +126,7 @@ class InitForm(App[InitChoices | None]):
105
126
  self._version = version
106
127
  self._admin = admin
107
128
  self._password = password
129
+ self._starters = installed_starters()
108
130
 
109
131
  def compose(self) -> ComposeResult:
110
132
  """Lay the whole form out on one screen."""
@@ -128,6 +150,12 @@ class InitForm(App[InitChoices | None]):
128
150
  id="services",
129
151
  )
130
152
 
153
+ yield Label("First pipelines (space toggles; copied from the installed corpus)", classes="section")
154
+ yield SelectionList[str](
155
+ *((starter_label(entry), entry.code) for entry in self._starters),
156
+ id="starters",
157
+ )
158
+
131
159
  yield Label("Packs (space toggles; pinned at this version)", classes="section")
132
160
  yield SelectionList[str](*((f"{pack.name} [dim]{pack.what}[/]", pack.name) for pack in PACKS), id="packs")
133
161
 
@@ -185,8 +213,10 @@ class InitForm(App[InitChoices | None]):
185
213
  # subscripted generic: the widgets are fetched untyped and narrowed here.
186
214
  services = cast("SelectionList[str]", self.query_one("#services"))
187
215
  packs = cast("SelectionList[str]", self.query_one("#packs"))
216
+ chosen = cast("SelectionList[str]", self.query_one("#starters"))
188
217
  picked_services: list[str] = list(services.selected)
189
218
  picked_packs: list[str] = list(packs.selected)
219
+ picked_starters: list[str] = list(chosen.selected)
190
220
  kind = self.kind
191
221
  chosen_services = tuple(code for code in (s.code for s in SERVICES) if code in picked_services)
192
222
  return InitChoices(
@@ -194,6 +224,7 @@ class InitForm(App[InitChoices | None]):
194
224
  services=chosen_services if kind == "compose" else DEFAULT_SERVICES,
195
225
  workflow=self.query_one("#workflow", Checkbox).value,
196
226
  packs=tuple(name for name in (p.name for p in PACKS) if name in picked_packs),
227
+ pipelines=tuple(entry.code for entry in self._starters if entry.code in picked_starters),
197
228
  admin=self.query_one("#admin", Input).value.strip() or "admin",
198
229
  password=self.query_one("#password", Input).value,
199
230
  )
@@ -203,13 +234,12 @@ class InitForm(App[InitChoices | None]):
203
234
  choices = self._collect()
204
235
  files = [
205
236
  "dirigent.yaml",
206
- "pipelines/hello-world.yaml",
237
+ *(f"pipelines/{code}.yaml" for code in choices.pipelines),
207
238
  ".dirigent/profiles.yaml",
208
239
  "pyproject.toml",
209
240
  "README.md",
210
241
  ]
211
242
  if choices.stack:
212
- files += [f"pipelines/{SERVICE_EXAMPLES[code][0]}" for code in choices.services]
213
243
  files += ["compose.yaml", "Dockerfile", ".env"]
214
244
  elif choices.instance:
215
245
  files += [".env", ".dirigent/state/"]
@@ -87,6 +87,7 @@ docker_app = typer.Typer(name="docker", help="The docker daemon this host's work
87
87
  app.add_typer(commands.runs_app, rich_help_panel=RUN_PANEL)
88
88
  app.add_typer(commands.pipeline_app, rich_help_panel=DEFINE_PANEL)
89
89
  app.add_typer(commands.blocks_app, rich_help_panel=DEFINE_PANEL)
90
+ app.add_typer(commands.examples_app, rich_help_panel=DEFINE_PANEL)
90
91
  app.add_typer(commands.schema_app, rich_help_panel=DEFINE_PANEL)
91
92
  app.add_typer(commands.connection_app, rich_help_panel=CONNECT_PANEL)
92
93
  app.add_typer(triggers.schedule_app, rich_help_panel=TRIGGER_PANEL)
@@ -824,6 +825,13 @@ def dev(
824
825
  "with its schedules paused; name it more than once to seed one directory after another.",
825
826
  ),
826
827
  ] = None,
828
+ seed_installed: Annotated[
829
+ bool,
830
+ typer.Option(
831
+ "--seed-installed",
832
+ help="Apply every document of every installed corpus, naming no directory at all.",
833
+ ),
834
+ ] = False,
827
835
  ) -> None:
828
836
  """Run the API and an embedded worker in one process, on SQLite, with no dependencies.
829
837
 
@@ -832,6 +840,9 @@ def dev(
832
840
  An instance that is there, made by dg init or by an earlier start, is the one that runs;
833
841
  --wipe-state deletes it first, and only a directory dirigent named itself is removed.
834
842
 
843
+ --seed-installed does the same with the corpus every installed plugin ships, so a
844
+ checkout is not needed; both may be given, and the directories go in first.
845
+
835
846
  --seed fills the instance from a directory of documents the moment it answers: the
836
847
  connections a file or a document declares are created first, then every document is
837
848
  applied with its schedules paused. A document an instance will not store is reported
@@ -870,8 +881,8 @@ def dev(
870
881
  bound = f"http://{address}:{listening}"
871
882
  emit(dev_started(settings, bound=bound, admin=admin, token=token, migrated=migrated))
872
883
  directories = seed or []
873
- bearer = token or (asyncio.run(seed_token(settings)) if directories else None)
874
- asyncio.run(_dev(settings, address, listening, seed=directories, bearer=bearer))
884
+ bearer = token or (asyncio.run(seed_token(settings)) if directories or seed_installed else None)
885
+ asyncio.run(_dev(settings, address, listening, seed=directories, installed=seed_installed, bearer=bearer))
875
886
 
876
887
 
877
888
  def clear_state(settings: Settings) -> Path | None:
@@ -1035,6 +1046,7 @@ async def _dev(
1035
1046
  port: int,
1036
1047
  *,
1037
1048
  seed: Sequence[Path] = (),
1049
+ installed: bool = False,
1038
1050
  bearer: str | None = None,
1039
1051
  ) -> None:
1040
1052
  """Run the API, the scheduler, a worker, and any seeding as tasks in one event loop.
@@ -1054,7 +1066,9 @@ async def _dev(
1054
1066
  api = uvicorn.Server(server_config)
1055
1067
  worker_task = asyncio.create_task(worker.run())
1056
1068
  ready = asyncio.create_task(_announce_ready(api))
1057
- seeding = asyncio.create_task(_seed(api, local_url(host, port), bearer, seed)) if seed else None
1069
+ seeding = (
1070
+ asyncio.create_task(_seed(api, local_url(host, port), bearer, seed, installed)) if seed or installed else None
1071
+ )
1058
1072
  try:
1059
1073
  await api.serve()
1060
1074
  finally:
@@ -1081,19 +1095,29 @@ async def _announce_ready(api: "uvicorn.Server") -> None:
1081
1095
  emit(make("process", at=datetime.now(UTC), message="ready", process="dev"))
1082
1096
 
1083
1097
 
1084
- async def _seed(api: "uvicorn.Server", url: str, bearer: str | None, directories: Sequence[Path]) -> None:
1085
- """Apply what --seed named, once the port is accepting, and write a record for each."""
1098
+ async def _seed(
1099
+ api: "uvicorn.Server",
1100
+ url: str,
1101
+ bearer: str | None,
1102
+ directories: Sequence[Path],
1103
+ installed: bool = False,
1104
+ ) -> None:
1105
+ """Apply what the seeding flags named, once the port is accepting, and record each document."""
1086
1106
  import asyncio
1087
1107
 
1088
- from dirigent_cli.seeding import seed_directories
1108
+ from dirigent_cli.seeding import seed_directories, seed_installed
1089
1109
  from dirigent_client import Dirigent, DirigentError
1090
1110
 
1091
1111
  while not api.started:
1092
1112
  await asyncio.sleep(0.05)
1093
1113
  try:
1094
1114
  async with Dirigent(url=url, token=bearer) as client:
1095
- async for record in seed_directories(client, directories):
1096
- emit(record)
1115
+ if directories:
1116
+ async for record in seed_directories(client, directories):
1117
+ emit(record)
1118
+ if installed:
1119
+ async for record in seed_installed(client):
1120
+ emit(record)
1097
1121
  except DirigentError as error:
1098
1122
  emit(make("error", at=datetime.now(UTC), level="error", message=f"seeding stopped: {error.message}"))
1099
1123
 
@@ -3,14 +3,18 @@
3
3
  import base64
4
4
  import os
5
5
  import re
6
+ from collections.abc import Sequence
6
7
  from pathlib import Path
7
- from typing import Any, Final, cast
8
+ from typing import TYPE_CHECKING, Any, Final, cast
8
9
 
9
10
  import yaml
10
11
  from pydantic import BaseModel, ConfigDict, Field
11
12
 
12
13
  from dirigent_core.configdocs import example_document, project_document
13
14
 
15
+ if TYPE_CHECKING:
16
+ from dirigent_core.examples import ExampleEntry
17
+
14
18
  #: The one file a project hand-edits: where its documents are, and what this instance sets.
15
19
  PROJECT_FILE: Final = "dirigent.yaml"
16
20
 
@@ -157,28 +161,6 @@ jobs:
157
161
  DG_TOKEN: ${{ secrets.DG_TOKEN }}
158
162
  """
159
163
 
160
- EXAMPLE_TEMPLATE = """\
161
- # The smallest thing dirigent can run: one step, one block, no parameters.
162
- #
163
- # value.const emits its configured value and touches nothing, so this runs with nothing
164
- # on the unsafe allowlist:
165
- #
166
- # dg apply
167
- # dg run hello-world --watch
168
-
169
- format: dirigent/v1
170
- kind: pipeline
171
- code: hello-world
172
- name: Hello world
173
- description: Emit a greeting, and nothing else.
174
-
175
- steps:
176
- greet:
177
- block: value.const
178
- config:
179
- value: "hello from dirigent"
180
- """
181
-
182
164
 
183
165
  class Service(BaseModel):
184
166
  """One optional service of the container stack, as the form and the flag name it."""
@@ -201,184 +183,6 @@ SERVICES: Final = (
201
183
 
202
184
  DEFAULT_SERVICES: Final = ("s3",)
203
185
 
204
- #: The example each service brings into `pipelines/`, so a stack with the service has one
205
- #: document that uses it the day it is made.
206
- SERVICE_EXAMPLES: Final = {
207
- "s3": (
208
- "s3-hello.yaml",
209
- """\
210
- # A greeting written to the stack's own bucket and read back, through the s3:// scheme.
211
- #
212
- # There is no S3 block: storage.write puts text in an object, storage.copy moves bytes between
213
- # schemes, and the stack's `migrate` service bootstraps the `artifacts` connection that serves
214
- # s3://. The bucket comes from the URI, so this one is the stack's:
215
- #
216
- # dg run s3-hello --watch
217
-
218
- format: dirigent/v1
219
- kind: pipeline
220
- code: s3-hello
221
- name: Hello, object storage
222
- description: Write a greeting to the bucket and copy it back, through s3://.
223
-
224
- requires:
225
- blocks:
226
- - storage.write
227
- - storage.copy
228
- connections:
229
- - artifacts
230
- storage:
231
- - s3
232
-
233
- steps:
234
- greet:
235
- block: storage.write
236
- config:
237
- target: "${run.scratch}/hello.txt"
238
- text: "hello from object storage"
239
-
240
- upload:
241
- block: storage.copy
242
- depends_on: [greet]
243
- config:
244
- source: "${steps.greet.output.uri}"
245
- target: "s3://dirigent/hello/${run.id}.txt"
246
-
247
- download:
248
- block: storage.copy
249
- depends_on: [upload]
250
- config:
251
- source: "s3://dirigent/hello/${run.id}.txt"
252
- target: "${run.scratch}/hello-back.txt"
253
- """,
254
- ),
255
- "docker": (
256
- "docker-hello.yaml",
257
- """\
258
- # A command run in a container on the workers' own daemon, the `docker` service.
259
- #
260
- # docker.run is on the stack's allowlist (DIRIGENT_ENABLED_UNSAFE_BLOCKS in .env) because the
261
- # daemon it reaches is the sidecar, never the host's. The image is pulled first, gets no
262
- # network, and is capped in memory and processes:
263
- #
264
- # dg run docker-hello --watch
265
-
266
- format: dirigent/v1
267
- kind: pipeline
268
- code: docker-hello
269
- name: Hello from a container
270
- description: Run a command in a container, with no network and the image pulled first.
271
-
272
- requires:
273
- blocks:
274
- - docker.run
275
- workers:
276
- - docker
277
-
278
- steps:
279
- greet:
280
- block: docker.run
281
- deadline: 5m
282
- config:
283
- image: alpine:3
284
- pull: true
285
- network: none
286
- argv: [echo, "hello from a container"]
287
- memory: 64mb
288
- pids_limit: 64
289
- """,
290
- ),
291
- "kafka": (
292
- "kafka-hello.yaml",
293
- """\
294
- # Three records published to the stack's Kafka broker and read back off the topic.
295
- #
296
- # The `migrate` service bootstraps the `kafka` connection and the `kafka-topic` service
297
- # creates the `hello` topic, so nothing has to be arranged first:
298
- #
299
- # dg run kafka-hello --watch
300
-
301
- format: dirigent/v1
302
- kind: pipeline
303
- code: kafka-hello
304
- name: Hello, Kafka
305
- description: Publish records to a topic and consume the same batch back.
306
-
307
- requires:
308
- blocks:
309
- - kafka.produce
310
- - kafka.consume
311
- connections:
312
- - kafka
313
-
314
- steps:
315
- publish:
316
- block: kafka.produce
317
- config:
318
- connection: kafka
319
- topic: hello
320
- records:
321
- - {greeting: hello, n: 1}
322
- - {greeting: hello, n: 2}
323
- - {greeting: hello, n: 3}
324
- timeout: 30s
325
-
326
- consume:
327
- block: kafka.consume
328
- depends_on: [publish]
329
- poll: 5s
330
- deadline: 5m
331
- config:
332
- connection: kafka
333
- topic: hello
334
- start: earliest
335
- min_messages: 3
336
- max_messages: 50
337
- poll_timeout: 5s
338
- value_format: json
339
- """,
340
- ),
341
- "rabbitmq": (
342
- "rabbitmq-hello.yaml",
343
- """\
344
- # A run that waits for a message on the stack's RabbitMQ queue, then hands the batch on.
345
- #
346
- # The `migrate` service bootstraps the `rabbitmq` connection and the `rabbitmq-queue`
347
- # service declares the `hello` queue. Publish something to it from the management UI at
348
- # http://127.0.0.1:15672 (dirigent / dirigent), and the run that is waiting takes it:
349
- #
350
- # dg run rabbitmq-hello --watch
351
-
352
- format: dirigent/v1
353
- kind: pipeline
354
- code: rabbitmq-hello
355
- name: Hello, RabbitMQ
356
- description: Wait for a message on a queue and hand the batch on.
357
-
358
- requires:
359
- blocks:
360
- - rabbitmq.consume
361
- connections:
362
- - rabbitmq
363
-
364
- steps:
365
- wait:
366
- block: rabbitmq.consume
367
- poll: 10s
368
- deadline: 1h
369
- on_timeout: skip
370
- config:
371
- connection: rabbitmq
372
- queue: hello
373
- min_messages: 1
374
- max_messages: 50
375
- poll_timeout: 5s
376
- ack: on_success
377
- value_format: json
378
- """,
379
- ),
380
- }
381
-
382
186
 
383
187
  class Pack(BaseModel):
384
188
  """One published pack the form and the flag can add to a project."""
@@ -400,6 +204,26 @@ TEMPLATES: Final = ("local", "compose", "documents")
400
204
  COMPOSE_TEMPLATE_NAME: Final = "compose"
401
205
 
402
206
 
207
+ def installed_starters() -> "list[ExampleEntry]":
208
+ """List the starters installed beside ``dg`` itself, which is what a new project may copy.
209
+
210
+ ``dg init`` writes the ``pyproject.toml`` that installs the packs, so a pack's own
211
+ starters are not installed yet when it runs; those arrive with ``dg pipeline new``.
212
+ """
213
+ from dirigent_core.examples import STARTER_TAG
214
+ from dirigent_core.plugins import load_plugin_host
215
+
216
+ return [entry for entry in load_plugin_host().examples() if STARTER_TAG in entry.tags]
217
+
218
+
219
+ def starter_documents(codes: Sequence[str]) -> dict[str, str]:
220
+ """Read each chosen starter as the text a copy of it writes, keyed by its code."""
221
+ from dirigent_cli.starters import instantiate
222
+
223
+ held = {entry.code: entry for entry in installed_starters()}
224
+ return {code: instantiate(held[code].source, code) for code in codes if code in held}
225
+
226
+
403
227
  class InitChoices(BaseModel):
404
228
  """Everything ``dg init`` decides, from the form or from the flags, before it writes."""
405
229
 
@@ -409,6 +233,9 @@ class InitChoices(BaseModel):
409
233
  services: tuple[str, ...] = DEFAULT_SERVICES
410
234
  workflow: bool = False
411
235
  packs: tuple[str, ...] = ()
236
+ pipelines: tuple[str, ...] = ()
237
+ """The starters this project opens with, copied from the corpus installed beside ``dg``."""
238
+
412
239
  admin: str = "admin"
413
240
  password: str = ""
414
241
 
@@ -441,6 +268,13 @@ def check_choices(choices: InitChoices) -> None:
441
268
  raise ProjectError(f"no pack named {pack!r}; the packs are {', '.join(sorted(packs))}")
442
269
  if choices.services != DEFAULT_SERVICES and not choices.stack:
443
270
  raise ProjectError("--service applies to the compose template alone; the other two run no stack")
271
+ starters = {entry.code for entry in installed_starters()}
272
+ for code in choices.pipelines:
273
+ if code not in starters:
274
+ raise ProjectError(
275
+ f"no starter named {code!r}; a document is copyable only when it wears the 'starter' tag, "
276
+ f"and `dg examples list --starter` names the ones installed here"
277
+ )
444
278
 
445
279
 
446
280
  COMPOSE_HEAD = """\
@@ -611,7 +445,7 @@ COMPOSE_S3 = """\
611
445
 
612
446
  # The bucket has to exist before a run writes to it, and nothing else creates it.
613
447
  s3-bucket:
614
- image: minio/mc:RELEASE.2025-04-16T18-13-26Z
448
+ image: quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z
615
449
  depends_on:
616
450
  s3:
617
451
  condition: service_healthy
@@ -1030,20 +864,20 @@ README_RUN: Final = {
1030
864
  "local": (
1031
865
  "uv sync",
1032
866
  "uv run dg dev",
867
+ "uv run dg pipeline new <starter>",
1033
868
  "uv run dg apply",
1034
- "uv run dg run hello-world --watch",
1035
869
  ),
1036
870
  "documents": (
1037
871
  "uv sync",
872
+ "uv run dg pipeline new <starter>",
1038
873
  "uv run dg apply --dry-run",
1039
874
  "uv run dg apply",
1040
- "uv run dg run hello-world --watch",
1041
875
  ),
1042
876
  "compose": (
1043
877
  "uv sync",
1044
878
  "docker compose up -d",
1045
879
  "uv run dg auth login --username admin",
1046
- "uv run dg run hello-world --watch",
880
+ "uv run dg pipeline new <starter>",
1047
881
  ),
1048
882
  }
1049
883
 
@@ -1070,11 +904,9 @@ def scaffold(directory: Path, choices: InitChoices, *, version: str = "0.0.0") -
1070
904
  skipped: list[Path] = []
1071
905
  directory.mkdir(parents=True, exist_ok=True)
1072
906
  written.append(_write(directory / PROJECT_FILE, PROJECT_TEMPLATE + "\n" + project_document()))
1073
- written.append(_write(directory / DEFAULT_PIPELINES_DIR / "hello-world.yaml", EXAMPLE_TEMPLATE))
1074
- if choices.stack:
1075
- for code in choices.services:
1076
- filename, document = SERVICE_EXAMPLES[code]
1077
- written.append(_write(directory / DEFAULT_PIPELINES_DIR / filename, document))
907
+ (directory / DEFAULT_PIPELINES_DIR).mkdir(parents=True, exist_ok=True)
908
+ for code, document in starter_documents(choices.pipelines).items():
909
+ written.append(_write(directory / DEFAULT_PIPELINES_DIR / f"{code}.yaml", document))
1078
910
  written.append(_write(directory / ".dirigent" / "profiles.yaml", PROFILES_TEMPLATE))
1079
911
  written.append(_write(directory / ".dirigent" / ".gitignore", STATE_IGNORE_TEMPLATE))
1080
912
  written.append(_write(directory / EXAMPLE_CONFIG_FILE, example_document()))
@@ -8,43 +8,15 @@ documents an instance will not store.
8
8
  from collections.abc import AsyncIterator, Mapping, Sequence
9
9
  from datetime import UTC, datetime
10
10
  from pathlib import Path
11
- from typing import Any, Final, cast
12
-
13
- import yaml
11
+ from typing import Any, cast
14
12
 
15
13
  from dirigent_cli.local import ConnectionSpec
16
14
  from dirigent_client import Dirigent, DirigentError, PlanAction, ProvenanceSource
17
15
  from dirigent_common import JsonMap
18
- from dirigent_core.documents import safe_load
19
- from dirigent_core.engine.definition import FORMAT_V1
16
+ from dirigent_core.documents import CARRIED, SUFFIXES, is_document, readable, safe_load
20
17
  from dirigent_core.protocol import Record, make
21
18
 
22
- #: The file endings a seed reads. Anything else under a seed directory is passed over, and so
23
- #: is a file of one of these that does not parse as a mapping.
24
- SUFFIXES: Final = (".yaml", ".yml", ".json")
25
-
26
- #: The sections a document may carry so that it runs alone under ``dg run --local``. An
27
- #: instance refuses to store a document carrying one, so the seed creates what they declare
28
- #: and applies the document without them.
29
- CARRIED: Final = ("connections", "schemas")
30
-
31
-
32
- def readable(directory: Path) -> list[tuple[Path, JsonMap]]:
33
- """Read every file under a directory that parses as a mapping, in a stable order."""
34
- found: list[tuple[Path, JsonMap]] = []
35
- for path in sorted(one for one in directory.rglob("*") if one.suffix in SUFFIXES and one.is_file()):
36
- try:
37
- parsed = safe_load(path.read_text())
38
- except (OSError, UnicodeDecodeError, yaml.YAMLError):
39
- continue
40
- if isinstance(parsed, dict):
41
- found.append((path, cast("JsonMap", parsed)))
42
- return found
43
-
44
-
45
- def is_document(raw: JsonMap) -> bool:
46
- """Say whether a parsed file is a document this instance reads."""
47
- return raw.get("format") == FORMAT_V1
19
+ __all__ = ["CARRIED", "SUFFIXES", "is_document", "readable", "seed_directories", "seed_installed", "specs"]
48
20
 
49
21
 
50
22
  def specs(declared: object) -> list[ConnectionSpec]:
@@ -196,3 +168,40 @@ def _refused(origin: str, reason: str) -> Record:
196
168
  def _reason(error: DirigentError) -> str:
197
169
  """Read a refusal as the one line a record carries."""
198
170
  return "; ".join(error.problems) or error.message
171
+
172
+
173
+ async def seed_installed(client: Dirigent) -> AsyncIterator[Record]:
174
+ """Apply every document of every installed corpus, the way a directory's documents go in.
175
+
176
+ The catalogue is what this build ships, so nothing is named on the command line and no
177
+ checkout has to be present. A document is attributed to the plugin that carries it.
178
+ """
179
+ from dirigent_core.plugins import load_plugin_host
180
+
181
+ pipelines = 0
182
+ refused = 0
183
+ connections: set[str] = set()
184
+ plugins: set[str] = set()
185
+ for entry in load_plugin_host().examples():
186
+ parsed = safe_load(entry.source)
187
+ if not isinstance(parsed, dict):
188
+ continue
189
+ plugins.add(entry.plugin)
190
+ async for record in _document(client, cast("JsonMap", parsed), f"{entry.plugin}:{entry.path}"):
191
+ kind = record["kind"]
192
+ if kind == "seed.applied":
193
+ pipelines += 1
194
+ elif kind == "seed.refused":
195
+ refused += 1
196
+ elif kind == "seed.connection":
197
+ connections.add(str(record["connection"]))
198
+ yield record
199
+ yield make(
200
+ "seed.done",
201
+ at=datetime.now(UTC),
202
+ message="seeded",
203
+ plugins=sorted(plugins),
204
+ pipelines=pipelines,
205
+ refused=refused,
206
+ connections=sorted(connections),
207
+ )
@@ -0,0 +1,121 @@
1
+ """Copying a starter: the two lines a copy rewrites, and nothing else.
2
+
3
+ A starter is a plain ``dirigent/v1`` document, not a template, so instantiating one is a
4
+ verbatim copy of its text with the top-level ``code:`` changed and ``starter`` taken off the
5
+ top-level ``tags:``. The edit is at text level rather than through a parser because the
6
+ teaching comments, the blank lines and the quoting are the point of copying a document
7
+ instead of generating one.
8
+ """
9
+
10
+ import re
11
+ from typing import Final
12
+
13
+ from dirigent_client import Requirements
14
+ from dirigent_core.examples import STARTER_TAG
15
+
16
+ #: The top-level ``code:`` line: no indentation, so a step's own ``code`` is never touched.
17
+ _CODE = re.compile(r"^code:[^\S\n]*(.*)$", re.MULTILINE)
18
+
19
+ #: The top-level ``tags:`` line, and whatever it carries on the same line.
20
+ _TAGS = re.compile(r"^tags:[^\S\n]*(.*)$", re.MULTILINE)
21
+
22
+ #: One entry of a block list under ``tags:``: two spaces, a dash, the value.
23
+ _TAG_ITEM = re.compile(r"^[^\S\n]*-[^\S\n]*(\S.*?)[^\S\n]*$")
24
+
25
+ _FLOW: Final = ("[", "]")
26
+
27
+
28
+ def instantiate(source: str, code: str) -> str:
29
+ """Copy a starter's text under a new code, with the ``starter`` tag dropped."""
30
+ return _retag(_recode(source, code))
31
+
32
+
33
+ def _recode(source: str, code: str) -> str:
34
+ """Rewrite the one top-level ``code:`` line, leaving every other line alone."""
35
+ return _CODE.sub(lambda _: f"code: {code}", source, count=1)
36
+
37
+
38
+ def _retag(source: str) -> str:
39
+ """Take ``starter`` off the top-level ``tags:``, whichever shape the list is written in.
40
+
41
+ A document whose only tag was ``starter`` loses the whole ``tags:`` entry: an empty list
42
+ says less than no list at all.
43
+ """
44
+ found = _TAGS.search(source)
45
+ if found is None:
46
+ return source
47
+ rest = found.group(1).strip()
48
+ if rest.startswith(_FLOW[0]):
49
+ return _rewrite_flow(source, found.start(), found.end(), rest)
50
+ if rest:
51
+ return source
52
+ return _rewrite_block(source, found.start(), found.end())
53
+
54
+
55
+ def _rewrite_flow(source: str, start: int, end: int, rest: str) -> str:
56
+ """Rewrite ``tags: [a, b, starter]``, dropping the entry when nothing is left."""
57
+ inner = rest.removeprefix(_FLOW[0]).removesuffix(_FLOW[1])
58
+ kept = [tag for tag in (one.strip() for one in inner.split(",")) if tag and tag != STARTER_TAG]
59
+ if not kept:
60
+ return _drop_line(source, start, end)
61
+ return source[:start] + f"tags: [{', '.join(kept)}]" + source[end:]
62
+
63
+
64
+ def _rewrite_block(source: str, start: int, end: int) -> str:
65
+ """Rewrite a block list under ``tags:``, dropping the whole entry when nothing is left."""
66
+ lines = source[end:].split("\n")
67
+ items: list[tuple[int, str]] = []
68
+ for index, line in enumerate(lines):
69
+ if index == 0 and not line.strip():
70
+ continue
71
+ matched = _TAG_ITEM.match(line)
72
+ if matched is None:
73
+ break
74
+ items.append((index, matched.group(1)))
75
+ if not items:
76
+ return source
77
+ dropped = [index for index, tag in items if tag == STARTER_TAG]
78
+ if not dropped:
79
+ return source
80
+ if len(dropped) == len(items):
81
+ return _drop_line(source, start, end + len("\n".join(lines[: items[-1][0] + 1])))
82
+ kept = [line for index, line in enumerate(lines) if index not in dropped]
83
+ return source[:end] + "\n".join(kept)
84
+
85
+
86
+ def _drop_line(source: str, start: int, end: int) -> str:
87
+ """Remove a whole entry, including the newline that ended it."""
88
+ tail = source[end:]
89
+ return source[:start] + tail.removeprefix("\n")
90
+
91
+
92
+ #: How a requirement's field is named when a summary counts it, singular and plural.
93
+ _COUNTED: Final = (
94
+ ("connections", "connection", "connections"),
95
+ ("schemas", "schema", "schemas"),
96
+ ("pipelines", "pipeline", "pipelines"),
97
+ ("storage", "storage scheme", "storage schemes"),
98
+ ("blocks", "block", "blocks"),
99
+ ("workers", "worker tag", "worker tags"),
100
+ )
101
+
102
+
103
+ def summary(requires: Requirements) -> str:
104
+ """Say what a document needs in one line: the counts, or nothing when it needs nothing."""
105
+ counted = [
106
+ f"{len(held)} {one if len(held) == 1 else many}"
107
+ for name, one, many in _COUNTED
108
+ if (held := getattr(requires, name))
109
+ ]
110
+ return ", ".join(counted) or "-"
111
+
112
+
113
+ def preflight(requires: Requirements) -> list[str]:
114
+ """List what has to exist on the instance before a copy of this document will apply."""
115
+ steps = [f"dg connection create KIND {code}" for code in requires.connections]
116
+ steps += [f"dg schema create {code}.json --code {code}" for code in requires.schemas]
117
+ steps += [f"apply the pipeline {code} it starts" for code in requires.pipelines]
118
+ steps += [f"a storage backend claiming {scheme}://" for scheme in requires.storage]
119
+ steps += [f"a pack contributing {block}" for block in requires.blocks]
120
+ steps += [f"a worker carrying the {tag} tag" for tag in requires.workers]
121
+ return steps
@@ -36,7 +36,20 @@ from dirigent_core.protocol import Record
36
36
  #: Fields a record carries for what is drawn beneath its line rather than for the line. A
37
37
  #: line carries whole values, and a run's every step is not a line's worth of them.
38
38
  BULKY: Final = frozenset(
39
- {"steps", "failures", "windows", "packages", "settings", "history", "problems", "issues", "files", "token"}
39
+ {
40
+ "steps",
41
+ "failures",
42
+ "windows",
43
+ "packages",
44
+ "settings",
45
+ "history",
46
+ "problems",
47
+ "issues",
48
+ "files",
49
+ "token",
50
+ "preflight",
51
+ "requires",
52
+ }
40
53
  )
41
54
 
42
55
 
@@ -299,14 +312,31 @@ def _initialised(record: Record) -> RenderableType | None:
299
312
  "\n [bold]uv sync[/]"
300
313
  "\n [bold]uv run dg dev[/]"
301
314
  f"\nThe UI is at http://127.0.0.1:3333 and {admin} logs in with the password you gave."
302
- "\n\nThen, in this directory, apply the example and run it:"
315
+ "\n\nThen pick a first pipeline out of the corpus, apply it and run it:"
316
+ "\n [bold]uv run dg examples list --starter[/]"
317
+ "\n [bold]uv run dg pipeline new <starter>[/]"
303
318
  "\n [bold]uv run dg apply[/]"
304
- "\n [bold]uv run dg run hello-world --watch[/]"
305
319
  "\n\n[dim]No DIRIGENT_SECRET_KEY is set, so a connection carrying a credential cannot be stored until it is.[/]"
306
320
  )
307
321
  return Group(*parts)
308
322
 
309
323
 
324
+ def _created(record: Record) -> RenderableType | None:
325
+ """Render a copied starter: where it landed, and what the instance must hold first."""
326
+ path = record.get("path")
327
+ if not path:
328
+ return None
329
+ parts: list[RenderableType] = [
330
+ f"Copied [bold]{escape(str(record.get('starter')))}[/] to [bold]{escape(str(path))}[/]."
331
+ ]
332
+ steps = _texts(record, "preflight")
333
+ if steps:
334
+ parts.append("\nIt will not apply until the instance holds what it needs:")
335
+ parts.extend(f" [yellow]-[/] {escape(step)}" for step in steps)
336
+ parts.append(f"\nThen:\n [bold]dg apply {escape(str(record.get('code')))}[/]")
337
+ return Group(*parts)
338
+
339
+
310
340
  def _config(record: Record) -> RenderableType | None:
311
341
  """Render the effective configuration as a setting-per-row table."""
312
342
  settings = record.get("settings")
@@ -411,4 +441,5 @@ RENDERERS: Final[Mapping[str, Callable[[Record], RenderableType | None]]] = {
411
441
  "process": _issued,
412
442
  "project.scaffolded": _scaffolded,
413
443
  "instance.initialised": _initialised,
444
+ "pipeline.created": _created,
414
445
  }
File without changes
File without changes