dirigent-cli 0.14.0__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.
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/PKG-INFO +2 -1
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/pyproject.toml +5 -1
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/pyproject.toml.orig +3 -1
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/commands.py +171 -2
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/init_form.py +35 -5
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/main.py +32 -8
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/project.py +42 -210
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/seeding.py +40 -31
- dirigent_cli-0.15.0/src/dirigent_cli/starters.py +121 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/summaries.py +34 -3
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/LICENSE +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/README.md +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/__init__.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/aliases.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/context.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/formatters.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/graph.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/health.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/local.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/output.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/params.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/profiles.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/py.typed +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/reaper.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/scaffold.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/schemas.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/sources.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/stream.py +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
- {dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/timing.py +0 -0
- {dirigent_cli-0.14.0 → 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.
|
|
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.
|
|
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.
|
|
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/
|
|
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 =
|
|
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(
|
|
1085
|
-
""
|
|
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
|
-
|
|
1096
|
-
|
|
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
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/operator.py.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl
RENAMED
|
File without changes
|
{dirigent_cli-0.14.0 → dirigent_cli-0.15.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|