dirigent-cli 0.9.4__tar.gz → 0.10.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.9.4 → dirigent_cli-0.10.0}/PKG-INFO +2 -1
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/pyproject.toml +2 -1
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/pyproject.toml.orig +2 -1
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/commands.py +87 -53
- dirigent_cli-0.10.0/src/dirigent_cli/init_form.py +253 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/project.py +576 -87
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/LICENSE +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/README.md +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/__init__.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/aliases.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/context.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/formatters.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/graph.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/health.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/local.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/main.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/output.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/params.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/profiles.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/py.typed +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/reaper.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/scaffold.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/schemas.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/sources.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/stream.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/summaries.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.0}/src/dirigent_cli/timing.py +0 -0
- {dirigent_cli-0.9.4 → dirigent_cli-0.10.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.10.0
|
|
4
4
|
Summary: The dirigent command line interface (dirigent / dg).
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
@@ -14,6 +14,7 @@ Requires-Dist: httpx2>=2.12.0
|
|
|
14
14
|
Requires-Dist: python-dotenv>=1.1.0
|
|
15
15
|
Requires-Dist: pyyaml>=6.0.3
|
|
16
16
|
Requires-Dist: rich>=15.0.0
|
|
17
|
+
Requires-Dist: textual>=8.0
|
|
17
18
|
Requires-Dist: tomli-w>=1.2.0
|
|
18
19
|
Requires-Dist: typer>=0.27.2
|
|
19
20
|
Requires-Dist: uvicorn>=0.52.4
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.0"
|
|
4
4
|
description = "The dirigent command line interface (dirigent / dg)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -17,6 +17,7 @@ dependencies = [
|
|
|
17
17
|
"python-dotenv>=1.1.0",
|
|
18
18
|
"pyyaml>=6.0.3",
|
|
19
19
|
"rich>=15.0.0",
|
|
20
|
+
"textual>=8.0",
|
|
20
21
|
"tomli-w>=1.2.0",
|
|
21
22
|
"typer>=0.27.2",
|
|
22
23
|
"uvicorn>=0.52.4",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.0"
|
|
4
4
|
description = "The dirigent command line interface (dirigent / dg)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -17,6 +17,7 @@ dependencies = [
|
|
|
17
17
|
"python-dotenv>=1.1.0",
|
|
18
18
|
"pyyaml>=6.0.3",
|
|
19
19
|
"rich>=15.0.0",
|
|
20
|
+
"textual>=8.0",
|
|
20
21
|
"tomli-w>=1.2.0",
|
|
21
22
|
"typer>=0.27.2",
|
|
22
23
|
"uvicorn>=0.52.4",
|
|
@@ -57,9 +57,9 @@ from dirigent_cli.output import (
|
|
|
57
57
|
)
|
|
58
58
|
from dirigent_cli.params import ParamError, build_params
|
|
59
59
|
from dirigent_cli.project import (
|
|
60
|
-
|
|
60
|
+
InitChoices,
|
|
61
61
|
ProjectError,
|
|
62
|
-
|
|
62
|
+
check_choices,
|
|
63
63
|
find_project,
|
|
64
64
|
scaffold,
|
|
65
65
|
write_token_env,
|
|
@@ -479,22 +479,32 @@ def _trigger_targets(dg: Session, wanted: set[str]) -> dict[str, TriggerTarget]:
|
|
|
479
479
|
def init_command(
|
|
480
480
|
ctx: typer.Context,
|
|
481
481
|
directory: Annotated[Path, typer.Argument(help="Where to create the instance and project.")] = Path(),
|
|
482
|
-
template: Annotated[
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
482
|
+
template: Annotated[
|
|
483
|
+
str | None,
|
|
484
|
+
typer.Option(help="Where it runs: local (dg dev on SQLite), compose (a container stack) or documents."),
|
|
485
|
+
] = None,
|
|
486
|
+
service: Annotated[
|
|
487
|
+
list[str] | None,
|
|
488
|
+
typer.Option("--service", help="A service the stack carries: s3, docker, kafka or rabbitmq. Repeatable."),
|
|
489
|
+
] = None,
|
|
490
|
+
workflow: Annotated[
|
|
491
|
+
bool, typer.Option("--workflow", help="Write a GitHub workflow that applies on merge.")
|
|
486
492
|
] = False,
|
|
493
|
+
pack: Annotated[
|
|
494
|
+
list[str] | None, typer.Option("--pack", help="A pack to add, such as dirigent-dhis2. Repeatable.")
|
|
495
|
+
] = None,
|
|
487
496
|
admin: Annotated[str, typer.Option(help="The first admin account's username.")] = "admin",
|
|
488
|
-
password: Annotated[str | None, typer.Option(help="Its password;
|
|
497
|
+
password: Annotated[str | None, typer.Option(help="Its password; asked for when omitted.")] = None,
|
|
489
498
|
) -> None:
|
|
490
499
|
"""Initialise a uv project and the instance it addresses, ready for `dg dev`.
|
|
491
500
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
the containers.
|
|
501
|
+
At a terminal, with no --template, one form asks everything: where the project runs, the
|
|
502
|
+
stack's services, packs, and the first admin. The flags answer the same questions for a
|
|
503
|
+
script. `--template local` writes the documents and a `pyproject.toml` pinning the running
|
|
504
|
+
dirigent, then creates the state directory, migrates the schema, creates the first admin
|
|
505
|
+
and mints it a token, kept in `.env`. `--template compose` writes the documents and a
|
|
506
|
+
container stack instead, and initialises nothing locally: the instance is the containers.
|
|
507
|
+
`--template documents` writes the documents alone, against an instance somebody else runs.
|
|
498
508
|
"""
|
|
499
509
|
import asyncio
|
|
500
510
|
|
|
@@ -505,34 +515,55 @@ def init_command(
|
|
|
505
515
|
if not state.chosen:
|
|
506
516
|
configure(output="console")
|
|
507
517
|
root = directory.resolve()
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
stack = template == COMPOSE_TEMPLATE_NAME
|
|
516
|
-
if stack and documents_only:
|
|
517
|
-
_init_fail("--documents-only does not apply to the compose template, which writes no instance to skip")
|
|
518
|
-
if stack and admin != "admin":
|
|
519
|
-
_init_fail("--admin does not apply to the compose template; the stack's first admin is named admin")
|
|
520
|
-
if not documents_only and not stack:
|
|
518
|
+
version = cli_version()
|
|
519
|
+
unasked = template is None and service is None and pack is None and not workflow
|
|
520
|
+
asked = unasked and sys.stdin.isatty() and sys.stdout.isatty()
|
|
521
|
+
# Refusing happens before anything is written or asked for, so a run that cannot finish
|
|
522
|
+
# has not half-created a project, and nobody fills a form for a command that was going
|
|
523
|
+
# to fail anyway.
|
|
524
|
+
if template in (None, "local"):
|
|
521
525
|
_refuse_an_existing_instance(root)
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
526
|
+
if asked:
|
|
527
|
+
from dirigent_cli.init_form import run_form
|
|
528
|
+
|
|
529
|
+
chosen = run_form(
|
|
530
|
+
root, version=version, admin=admin, password=password or os.environ.get(BOOTSTRAP_PASSWORD_ENV, "")
|
|
531
|
+
)
|
|
532
|
+
if chosen is None:
|
|
533
|
+
_init_fail("cancelled; nothing was written")
|
|
534
|
+
choices = chosen
|
|
535
|
+
else:
|
|
536
|
+
choices = InitChoices(
|
|
537
|
+
template=template or "local",
|
|
538
|
+
services=tuple(service) if service is not None else InitChoices().services,
|
|
539
|
+
workflow=workflow,
|
|
540
|
+
packs=tuple(pack or ()),
|
|
541
|
+
admin=admin,
|
|
542
|
+
password=password or os.environ.get(BOOTSTRAP_PASSWORD_ENV) or "",
|
|
543
|
+
)
|
|
544
|
+
try:
|
|
545
|
+
check_choices(choices)
|
|
546
|
+
except ProjectError as error:
|
|
547
|
+
_init_fail(str(error))
|
|
548
|
+
if choices.stack and admin != "admin":
|
|
549
|
+
_init_fail("--admin does not apply to the compose template; the stack's first admin is named admin")
|
|
550
|
+
if choices.template != "documents" and not choices.password:
|
|
551
|
+
choices = choices.model_copy(update={"password": _prompt_for_a_password(stack=choices.stack)})
|
|
552
|
+
if choices.template != "documents" and len(choices.password) < MIN_PASSWORD_LENGTH:
|
|
528
553
|
_init_fail(str(WeakPassword()))
|
|
529
|
-
version = cli_version()
|
|
530
554
|
try:
|
|
531
|
-
made = scaffold(directory,
|
|
555
|
+
made = scaffold(directory, choices, version=version)
|
|
532
556
|
except ProjectError as error:
|
|
533
557
|
_init_fail(str(error))
|
|
534
558
|
left = [_within(path, directory) for path in made.skipped]
|
|
535
|
-
|
|
559
|
+
described = {
|
|
560
|
+
"template": choices.template,
|
|
561
|
+
"version": version,
|
|
562
|
+
**({"services": list(choices.services)} if choices.stack else {}),
|
|
563
|
+
**({"workflow": True} if choices.workflow else {}),
|
|
564
|
+
**({"packs": list(choices.packs)} if choices.packs else {}),
|
|
565
|
+
}
|
|
566
|
+
if not choices.instance:
|
|
536
567
|
starting = [
|
|
537
568
|
"uv sync",
|
|
538
569
|
"docker compose up -d",
|
|
@@ -541,30 +572,28 @@ def init_command(
|
|
|
541
572
|
emit_fact(
|
|
542
573
|
"project.scaffolded",
|
|
543
574
|
message="scaffolded",
|
|
544
|
-
template=template,
|
|
545
575
|
directory=str(directory),
|
|
546
|
-
version=version,
|
|
547
576
|
files=[_within(path, directory) for path in made.files],
|
|
577
|
+
**described,
|
|
548
578
|
**({"skipped": left} if left else {}),
|
|
549
|
-
**({"next": starting} if stack else {}),
|
|
579
|
+
**({"next": starting} if choices.stack else {}),
|
|
550
580
|
)
|
|
551
581
|
return
|
|
552
582
|
settings = instance_settings(root)
|
|
553
583
|
migrated = migrations.head_revision(settings) or "none"
|
|
554
584
|
migrations.upgrade("head", settings)
|
|
555
|
-
token = asyncio.run(first_admin(settings, admin,
|
|
585
|
+
token = asyncio.run(first_admin(settings, choices.admin, choices.password))
|
|
556
586
|
env_file = write_token_env(root, token)
|
|
557
587
|
emit_fact(
|
|
558
588
|
"instance.initialised",
|
|
559
589
|
message="initialised",
|
|
560
|
-
template=template,
|
|
561
590
|
directory=str(root),
|
|
562
591
|
state=STATE_DIR,
|
|
563
592
|
schema=migrated,
|
|
564
|
-
admin=admin,
|
|
593
|
+
admin=choices.admin,
|
|
565
594
|
token=token,
|
|
566
|
-
version=version,
|
|
567
595
|
files=[_within(path, root) for path in [*made.files, env_file]],
|
|
596
|
+
**described,
|
|
568
597
|
**({"skipped": [_within(path, root) for path in made.skipped]} if made.skipped else {}),
|
|
569
598
|
)
|
|
570
599
|
|
|
@@ -586,14 +615,19 @@ def instance_settings(root: Path) -> Settings:
|
|
|
586
615
|
)
|
|
587
616
|
|
|
588
617
|
|
|
589
|
-
def _init_fail(message: str, *,
|
|
590
|
-
"""Refuse an init: a record where records were asked for, plain sentences otherwise.
|
|
618
|
+
def _init_fail(message: str, *, ways: Sequence[tuple[str, str]] = ()) -> NoReturn:
|
|
619
|
+
"""Refuse an init: a record where records were asked for, plain sentences otherwise.
|
|
620
|
+
|
|
621
|
+
Each way out is a command and what it does; the record carries them as one sentence
|
|
622
|
+
each, and the rendering sets the commands in a column of their own.
|
|
623
|
+
"""
|
|
591
624
|
if json_mode():
|
|
592
|
-
refuse(message, problems=
|
|
625
|
+
refuse(message, problems=[f"{command} {what}" for command, what in ways])
|
|
593
626
|
else:
|
|
594
|
-
error_console.print(message, highlight=False
|
|
595
|
-
for
|
|
596
|
-
|
|
627
|
+
error_console.print(f"[red]{escape(message)}[/]", highlight=False)
|
|
628
|
+
width = max((len(command) for command, _ in ways), default=0)
|
|
629
|
+
for command, what in ways:
|
|
630
|
+
error_console.print(f" [bold]{escape(command).ljust(width)}[/] [dim]{escape(what)}[/]", highlight=False)
|
|
597
631
|
raise typer.Exit(code=1)
|
|
598
632
|
|
|
599
633
|
|
|
@@ -606,11 +640,11 @@ def _refuse_an_existing_instance(root: Path) -> None:
|
|
|
606
640
|
existing = instance_settings(root).sqlite_path
|
|
607
641
|
if existing is not None and existing.exists():
|
|
608
642
|
_init_fail(
|
|
609
|
-
f"
|
|
610
|
-
|
|
611
|
-
"dg dev starts it",
|
|
612
|
-
"dg db upgrade brings its schema forward",
|
|
613
|
-
"dg init --documents
|
|
643
|
+
f"this directory holds an instance already: {existing}",
|
|
644
|
+
ways=[
|
|
645
|
+
("dg dev", "starts it"),
|
|
646
|
+
("dg db upgrade", "brings its schema forward"),
|
|
647
|
+
("dg init --template documents", "scaffolds documents beside it"),
|
|
614
648
|
],
|
|
615
649
|
)
|
|
616
650
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""The ``dg init`` form: one Textual screen instead of a run of flags and prompts.
|
|
2
|
+
|
|
3
|
+
Everything is visible at once and revised before anything is written: where the project
|
|
4
|
+
runs, which services the stack carries, which packs come along, and the first admin. The
|
|
5
|
+
form returns the choices and the caller scaffolds, so quitting leaves no half-made project.
|
|
6
|
+
Nothing here touches the disk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
17
|
+
from textual.theme import Theme
|
|
18
|
+
from textual.widgets import Button, Checkbox, Input, Label, RadioButton, RadioSet, SelectionList
|
|
19
|
+
|
|
20
|
+
from dirigent_cli.project import (
|
|
21
|
+
DEFAULT_SERVICES,
|
|
22
|
+
PACKS,
|
|
23
|
+
SERVICE_EXAMPLES,
|
|
24
|
+
SERVICES,
|
|
25
|
+
InitChoices,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
MIN_PASSWORD_LENGTH = 8
|
|
29
|
+
|
|
30
|
+
#: The web UI's dark palette, so the form is the same product as the screen behind the door:
|
|
31
|
+
#: the amber accent on the near-black ground, with the same surfaces, inks and status colours.
|
|
32
|
+
DIRIGENT_THEME = Theme(
|
|
33
|
+
name="dirigent",
|
|
34
|
+
primary="#e9b452",
|
|
35
|
+
secondary="#00a7e2",
|
|
36
|
+
accent="#e9b452",
|
|
37
|
+
foreground="#eaeff5",
|
|
38
|
+
background="#070a0f",
|
|
39
|
+
surface="#10151c",
|
|
40
|
+
panel="#191f26",
|
|
41
|
+
success="#5cb85c",
|
|
42
|
+
warning="#f9b64f",
|
|
43
|
+
error="#ed5350",
|
|
44
|
+
dark=True,
|
|
45
|
+
variables={
|
|
46
|
+
"input-cursor-background": "#e9b452",
|
|
47
|
+
"input-cursor-foreground": "#0a0e12",
|
|
48
|
+
"input-selection-background": "#e9b45240",
|
|
49
|
+
"block-cursor-background": "#e9b452",
|
|
50
|
+
"block-cursor-foreground": "#0a0e12",
|
|
51
|
+
"border": "#323941",
|
|
52
|
+
"border-blurred": "#191f26",
|
|
53
|
+
"footer-background": "#10151c",
|
|
54
|
+
"button-color-foreground": "#0a0e12",
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
#: The three places a project runs, in the order the form lists them.
|
|
59
|
+
KINDS = (
|
|
60
|
+
("local", "Local: one process on SQLite", "dg dev; one person, one machine"),
|
|
61
|
+
("compose", "A container stack", "compose: PostgreSQL, workers, the published image"),
|
|
62
|
+
("documents", "Documents only", "against an instance somebody else runs"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InitForm(App[InitChoices | None]):
|
|
67
|
+
"""The scaffolding form: kind, services, packs, workflow, admin, on one screen."""
|
|
68
|
+
|
|
69
|
+
TITLE = "dg init"
|
|
70
|
+
|
|
71
|
+
CSS = """
|
|
72
|
+
Screen { padding: 1 2; }
|
|
73
|
+
#body { height: 1fr; }
|
|
74
|
+
Label { width: 1fr; }
|
|
75
|
+
.section { color: $text-muted; margin-top: 1; }
|
|
76
|
+
.hint { color: $text-muted; }
|
|
77
|
+
SelectionList > .selection-list--button { color: $surface; background: $surface; }
|
|
78
|
+
SelectionList > .selection-list--button-selected { color: $success; background: $surface; }
|
|
79
|
+
SelectionList > .selection-list--button-highlighted { color: $surface; background: $surface; }
|
|
80
|
+
SelectionList > .selection-list--button-selected-highlighted { color: $success; background: $surface; }
|
|
81
|
+
Checkbox > .toggle--button { color: $surface; background: $surface; }
|
|
82
|
+
Checkbox.-on > .toggle--button { color: $success; background: $surface; }
|
|
83
|
+
#kind { height: auto; }
|
|
84
|
+
#services, #packs { height: auto; border: round $border; }
|
|
85
|
+
#admin-block { height: auto; }
|
|
86
|
+
#admin-row { height: auto; }
|
|
87
|
+
#admin-row Input { width: 1fr; margin-right: 2; }
|
|
88
|
+
#problem { color: $error; height: auto; }
|
|
89
|
+
#files { color: $text-muted; height: auto; margin-top: 1; }
|
|
90
|
+
#actions { height: auto; margin-top: 1; }
|
|
91
|
+
#actions Button { margin-right: 2; }
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
BINDINGS = [
|
|
95
|
+
Binding("escape", "cancel", "Cancel", show=True),
|
|
96
|
+
Binding("ctrl+s", "create", "Create", show=True, priority=True),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def __init__(self, directory: Path, *, version: str, admin: str = "admin", password: str = "") -> None:
|
|
100
|
+
"""Open on a directory, the runtime it will pin, and whatever the flags already said."""
|
|
101
|
+
super().__init__()
|
|
102
|
+
self.register_theme(DIRIGENT_THEME)
|
|
103
|
+
self.theme = "dirigent"
|
|
104
|
+
self._directory = directory
|
|
105
|
+
self._version = version
|
|
106
|
+
self._admin = admin
|
|
107
|
+
self._password = password
|
|
108
|
+
|
|
109
|
+
def compose(self) -> ComposeResult:
|
|
110
|
+
"""Lay the whole form out on one screen."""
|
|
111
|
+
yield Label(f"New dirigent project: {self._directory.name}")
|
|
112
|
+
yield Label(
|
|
113
|
+
f"The documents, the runtime it pins ({self._version}) and its instance, all in this directory.",
|
|
114
|
+
classes="hint",
|
|
115
|
+
)
|
|
116
|
+
with VerticalScroll(id="body"):
|
|
117
|
+
yield Label("Where it runs", classes="section")
|
|
118
|
+
with RadioSet(id="kind"):
|
|
119
|
+
for index, (code, title, what) in enumerate(KINDS):
|
|
120
|
+
yield RadioButton(f"{title} [dim]{what}[/]", value=index == 0, id=f"kind-{code}")
|
|
121
|
+
|
|
122
|
+
yield Label("Services in the stack (space toggles)", classes="section", id="services-label")
|
|
123
|
+
yield SelectionList[str](
|
|
124
|
+
*(
|
|
125
|
+
(f"{service.title} [dim]{service.what}[/]", service.code, service.code in DEFAULT_SERVICES)
|
|
126
|
+
for service in SERVICES
|
|
127
|
+
),
|
|
128
|
+
id="services",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
yield Label("Packs (space toggles; pinned at this version)", classes="section")
|
|
132
|
+
yield SelectionList[str](*((f"{pack.name} [dim]{pack.what}[/]", pack.name) for pack in PACKS), id="packs")
|
|
133
|
+
|
|
134
|
+
yield Checkbox("A GitHub workflow that applies the project on merge", id="workflow")
|
|
135
|
+
|
|
136
|
+
with Vertical(id="admin-block"):
|
|
137
|
+
yield Label("First admin", classes="section")
|
|
138
|
+
with Horizontal(id="admin-row"):
|
|
139
|
+
yield Input(value=self._admin, placeholder="username", id="admin")
|
|
140
|
+
yield Input(value=self._password, placeholder="password", password=True, id="password")
|
|
141
|
+
yield Input(value=self._password, placeholder="confirm", password=True, id="confirm")
|
|
142
|
+
yield Label(f"At least {MIN_PASSWORD_LENGTH} characters, typed twice.", classes="hint")
|
|
143
|
+
|
|
144
|
+
yield Label("", id="problem")
|
|
145
|
+
yield Label("", id="files")
|
|
146
|
+
with Horizontal(id="actions"):
|
|
147
|
+
yield Button("Create project", variant="primary", id="create")
|
|
148
|
+
yield Button("Cancel", id="cancel")
|
|
149
|
+
|
|
150
|
+
def on_mount(self) -> None:
|
|
151
|
+
"""Open on the kind, and say what the defaults will write."""
|
|
152
|
+
self.query_one("#kind", RadioSet).focus()
|
|
153
|
+
self._follow_kind()
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def kind(self) -> str:
|
|
157
|
+
"""The place the project runs, as the radio set has it."""
|
|
158
|
+
pressed = self.query_one("#kind", RadioSet).pressed_button
|
|
159
|
+
return pressed.id.removeprefix("kind-") if pressed is not None and pressed.id else "local"
|
|
160
|
+
|
|
161
|
+
def _follow_kind(self) -> None:
|
|
162
|
+
"""Show the services for the stack alone, and the admin for the two kinds that make one."""
|
|
163
|
+
kind = self.kind
|
|
164
|
+
stack = kind == "compose"
|
|
165
|
+
self.query_one("#services-label").display = stack
|
|
166
|
+
self.query_one("#services").display = stack
|
|
167
|
+
self.query_one("#admin-block").display = kind != "documents"
|
|
168
|
+
self._refresh_files()
|
|
169
|
+
|
|
170
|
+
def on_radio_set_changed(self, _event: RadioSet.Changed) -> None:
|
|
171
|
+
"""A kind decides which sections apply."""
|
|
172
|
+
self._follow_kind()
|
|
173
|
+
|
|
174
|
+
def on_selection_list_selected_changed(self, _event: SelectionList.SelectedChanged[str]) -> None:
|
|
175
|
+
"""A toggle changes what is written."""
|
|
176
|
+
self._refresh_files()
|
|
177
|
+
|
|
178
|
+
def on_checkbox_changed(self, _event: Checkbox.Changed) -> None:
|
|
179
|
+
"""The workflow box changes what is written."""
|
|
180
|
+
self._refresh_files()
|
|
181
|
+
|
|
182
|
+
def _collect(self) -> InitChoices:
|
|
183
|
+
"""Read the form into choices, as they stand."""
|
|
184
|
+
# query_one's type argument is a runtime isinstance check, so it cannot take a
|
|
185
|
+
# subscripted generic: the widgets are fetched untyped and narrowed here.
|
|
186
|
+
services = cast("SelectionList[str]", self.query_one("#services"))
|
|
187
|
+
packs = cast("SelectionList[str]", self.query_one("#packs"))
|
|
188
|
+
picked_services: list[str] = list(services.selected)
|
|
189
|
+
picked_packs: list[str] = list(packs.selected)
|
|
190
|
+
kind = self.kind
|
|
191
|
+
chosen_services = tuple(code for code in (s.code for s in SERVICES) if code in picked_services)
|
|
192
|
+
return InitChoices(
|
|
193
|
+
template=kind,
|
|
194
|
+
services=chosen_services if kind == "compose" else DEFAULT_SERVICES,
|
|
195
|
+
workflow=self.query_one("#workflow", Checkbox).value,
|
|
196
|
+
packs=tuple(name for name in (p.name for p in PACKS) if name in picked_packs),
|
|
197
|
+
admin=self.query_one("#admin", Input).value.strip() or "admin",
|
|
198
|
+
password=self.query_one("#password", Input).value,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _refresh_files(self) -> None:
|
|
202
|
+
"""Say what Create will write, so the file list after the fact is not the first sight of it."""
|
|
203
|
+
choices = self._collect()
|
|
204
|
+
files = [
|
|
205
|
+
"dirigent.yaml",
|
|
206
|
+
"pipelines/hello-world.yaml",
|
|
207
|
+
".dirigent/profiles.yaml",
|
|
208
|
+
"pyproject.toml",
|
|
209
|
+
"README.md",
|
|
210
|
+
]
|
|
211
|
+
if choices.stack:
|
|
212
|
+
files += [f"pipelines/{SERVICE_EXAMPLES[code][0]}" for code in choices.services]
|
|
213
|
+
files += ["compose.yaml", "Dockerfile", ".env"]
|
|
214
|
+
elif choices.instance:
|
|
215
|
+
files += [".env", ".dirigent/state/"]
|
|
216
|
+
if choices.workflow:
|
|
217
|
+
files.append(".github/workflows/dirigent.yml")
|
|
218
|
+
self.query_one("#files", Label).update("Will write: " + ", ".join(files))
|
|
219
|
+
|
|
220
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
221
|
+
"""The two buttons are the two bindings."""
|
|
222
|
+
if event.button.id == "create":
|
|
223
|
+
self.action_create()
|
|
224
|
+
else:
|
|
225
|
+
self.action_cancel()
|
|
226
|
+
|
|
227
|
+
def action_cancel(self) -> None:
|
|
228
|
+
"""Leave with nothing, so nothing is written."""
|
|
229
|
+
self.exit(None)
|
|
230
|
+
|
|
231
|
+
def action_create(self) -> None:
|
|
232
|
+
"""Refuse an admin the instance could not be made with, else hand the choices back."""
|
|
233
|
+
choices = self._collect()
|
|
234
|
+
problem = self.query_one("#problem", Label)
|
|
235
|
+
if choices.template != "documents":
|
|
236
|
+
confirm = self.query_one("#confirm", Input).value
|
|
237
|
+
if choices.stack and choices.admin != "admin":
|
|
238
|
+
problem.update("The stack's first admin is named admin.")
|
|
239
|
+
return
|
|
240
|
+
if len(choices.password) < MIN_PASSWORD_LENGTH:
|
|
241
|
+
problem.update(f"A password must be at least {MIN_PASSWORD_LENGTH} characters.")
|
|
242
|
+
self.query_one("#password", Input).focus()
|
|
243
|
+
return
|
|
244
|
+
if confirm != choices.password:
|
|
245
|
+
problem.update("The two passwords differ.")
|
|
246
|
+
self.query_one("#confirm", Input).focus()
|
|
247
|
+
return
|
|
248
|
+
self.exit(choices)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def run_form(directory: Path, *, version: str, admin: str = "admin", password: str = "") -> InitChoices | None:
|
|
252
|
+
"""Run the form, returning the choices, or None when the person quit."""
|
|
253
|
+
return InitForm(directory, version=version, admin=admin, password=password).run()
|