dirigent-cli 0.16.4__tar.gz → 0.16.6__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.16.4 → dirigent_cli-0.16.6}/PKG-INFO +10 -8
  2. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/pyproject.toml +12 -8
  3. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/pyproject.toml.orig +12 -8
  4. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/commands.py +33 -8
  5. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/main.py +33 -1
  6. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/output.py +16 -5
  7. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/project.py +11 -6
  8. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/summaries.py +3 -1
  9. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/LICENSE +0 -0
  10. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/README.md +0 -0
  11. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/__init__.py +0 -0
  12. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/aliases.py +0 -0
  13. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/context.py +0 -0
  14. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/formatters.py +0 -0
  15. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/graph.py +0 -0
  16. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/health.py +0 -0
  17. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/init_form.py +0 -0
  18. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/local.py +0 -0
  19. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/params.py +0 -0
  20. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/profiles.py +0 -0
  21. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/py.typed +0 -0
  22. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/reaper.py +0 -0
  23. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/scaffold.py +0 -0
  24. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/schemas.py +0 -0
  25. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/seeding.py +0 -0
  26. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/sources.py +0 -0
  27. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/starters.py +0 -0
  28. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/stream.py +0 -0
  29. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  30. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  31. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  32. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  33. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  34. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/timing.py +0 -0
  35. {dirigent_cli-0.16.4 → dirigent_cli-0.16.6}/src/dirigent_cli/triggers.py +0 -0
@@ -1,16 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.16.4
3
+ Version: 0.16.6
4
4
  Summary: The dirigent command line interface (dirigent / dg).
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
7
- Requires-Dist: dirigent-blocks==0.16.4
8
- Requires-Dist: dirigent-client==0.16.4
9
- Requires-Dist: dirigent-common==0.16.4
10
- Requires-Dist: dirigent-core==0.16.4
11
- Requires-Dist: dirigent-examples==0.16.4
12
- Requires-Dist: dirigent-plugin==0.16.4
13
- Requires-Dist: dirigent-server==0.16.4
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Requires-Dist: dirigent-blocks==0.16.6
10
+ Requires-Dist: dirigent-client==0.16.6
11
+ Requires-Dist: dirigent-common==0.16.6
12
+ Requires-Dist: dirigent-core==0.16.6
13
+ Requires-Dist: dirigent-examples==0.16.6
14
+ Requires-Dist: dirigent-plugin==0.16.6
15
+ Requires-Dist: dirigent-server==0.16.6
14
16
  Requires-Dist: httpx2>=2.12.0
15
17
  Requires-Dist: python-dotenv>=1.1.0
16
18
  Requires-Dist: pyyaml>=6.0.3
@@ -1,19 +1,23 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.16.4"
3
+ version = "0.16.6"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  license = "LicenseRef-Proprietary"
8
8
  license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
9
13
  dependencies = [
10
- "dirigent-blocks==0.16.4",
11
- "dirigent-client==0.16.4",
12
- "dirigent-common==0.16.4",
13
- "dirigent-core==0.16.4",
14
- "dirigent-examples==0.16.4",
15
- "dirigent-plugin==0.16.4",
16
- "dirigent-server==0.16.4",
14
+ "dirigent-blocks==0.16.6",
15
+ "dirigent-client==0.16.6",
16
+ "dirigent-common==0.16.6",
17
+ "dirigent-core==0.16.6",
18
+ "dirigent-examples==0.16.6",
19
+ "dirigent-plugin==0.16.6",
20
+ "dirigent-server==0.16.6",
17
21
  "httpx2>=2.12.0",
18
22
  "python-dotenv>=1.1.0",
19
23
  "pyyaml>=6.0.3",
@@ -1,19 +1,23 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.16.4"
3
+ version = "0.16.6"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  license = "LicenseRef-Proprietary"
8
8
  license-files = ["LICENSE"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.13",
12
+ ]
9
13
  dependencies = [
10
- "dirigent-blocks==0.16.4",
11
- "dirigent-client==0.16.4",
12
- "dirigent-common==0.16.4",
13
- "dirigent-core==0.16.4",
14
- "dirigent-examples==0.16.4",
15
- "dirigent-plugin==0.16.4",
16
- "dirigent-server==0.16.4",
14
+ "dirigent-blocks==0.16.6",
15
+ "dirigent-client==0.16.6",
16
+ "dirigent-common==0.16.6",
17
+ "dirigent-core==0.16.6",
18
+ "dirigent-examples==0.16.6",
19
+ "dirigent-plugin==0.16.6",
20
+ "dirigent-server==0.16.6",
17
21
  "httpx2>=2.12.0",
18
22
  "python-dotenv>=1.1.0",
19
23
  "pyyaml>=6.0.3",
@@ -62,7 +62,7 @@ from dirigent_cli.project import (
62
62
  check_choices,
63
63
  find_project,
64
64
  scaffold,
65
- write_token_env,
65
+ write_instance_env,
66
66
  )
67
67
  from dirigent_cli.scaffold import ScaffoldedRecord, ScaffoldError, ScaffoldRecord, scaffold_pack
68
68
  from dirigent_cli.sources import (
@@ -170,6 +170,11 @@ def fail(message: str) -> NoReturn:
170
170
  raise typer.Exit(code=1)
171
171
 
172
172
 
173
+ def at_a_terminal() -> bool:
174
+ """Report whether somebody is there to answer a prompt."""
175
+ return sys.stdin.isatty()
176
+
177
+
173
178
  def ask(label: str, *, hide: bool = False) -> str:
174
179
  """Read one value from the terminal, refusing under --json where nothing can answer.
175
180
 
@@ -532,9 +537,10 @@ def init_command(
532
537
  stack's services, packs, and the first admin. The flags answer the same questions for a
533
538
  script. `--template local` writes the documents and a `pyproject.toml` pinning the running
534
539
  dirigent, then creates the state directory, migrates the schema, creates the first admin
535
- and mints it a token, kept in `.env`. `--template compose` writes the documents and a
536
- container stack instead, and initialises nothing locally: the instance is the containers.
537
- `--template documents` writes the documents alone, against an instance somebody else runs.
540
+ and mints it a token and a secret key, both kept in `.env`. `--template compose` writes the
541
+ documents and a container stack instead, and initialises nothing locally: the instance is
542
+ the containers. `--template documents` writes the documents alone, against an instance
543
+ somebody else runs.
538
544
  """
539
545
  import asyncio
540
546
 
@@ -611,7 +617,7 @@ def init_command(
611
617
  migrated = migrations.head_revision(settings) or "none"
612
618
  migrations.upgrade("head", settings)
613
619
  token = asyncio.run(first_admin(settings, choices.admin, choices.password))
614
- env_file = write_token_env(root, token)
620
+ env_file = write_instance_env(root, token)
615
621
  emit_fact(
616
622
  "instance.initialised",
617
623
  message="initialised",
@@ -885,11 +891,21 @@ def parse_params(
885
891
  schema: dict[str, Any] | None = None,
886
892
  files: list[Path] | None = None,
887
893
  ) -> dict[str, Any]:
888
- """Build a run's parameters from files and flags, coerced against the pipeline's schema."""
894
+ """Build a run's parameters from files and flags, coerced against the pipeline's schema.
895
+
896
+ An empty value for a secret field means unset: ``--set api_token=`` leaves the field out
897
+ altogether, so nothing stores a credential that every read afterwards reports as set.
898
+ """
889
899
  try:
890
- return build_params(schema or {}, pairs=pairs or [], files=files or [])
900
+ built = build_params(schema or {}, pairs=pairs or [], files=files or [])
891
901
  except ParamError as error:
892
902
  fail(str(error))
903
+ properties = cast("dict[str, Any]", (schema or {}).get("properties") or {})
904
+ return {
905
+ name: value
906
+ for name, value in built.items()
907
+ if value != "" or not _is_secret_field(cast("dict[str, Any]", properties.get(name) or {}))
908
+ }
893
909
 
894
910
 
895
911
  #: What separates the two ends of a ``--window`` value.
@@ -2224,12 +2240,19 @@ def connection_create(
2224
2240
  """Create a connection, prompting for the secret fields without echoing them.
2225
2241
 
2226
2242
  Prompting happens only on a terminal; in a script every value must arrive via ``--set``.
2243
+ An invocation carrying ``--set`` is prompted only for what it cannot do without: a
2244
+ required field it left out. A bare invocation is the interactive form and is offered
2245
+ every secret the kind declares.
2227
2246
  """
2228
2247
  with client_for(state_of(ctx)) as dg:
2229
2248
  schema = _connection_schema(dg.call(dg.blocks.catalog()), kind_id)
2230
2249
  config = parse_params(set_value, schema=schema)
2231
2250
  required = set(cast("list[str]", schema.get("required") or []))
2232
- interactive = sys.stdin.isatty() and not json_mode()
2251
+ interactive = at_a_terminal() and not json_mode()
2252
+ # An invocation that carried --set said what it wanted, so only a required secret is
2253
+ # still asked for there: an optional one it left out is left out. With no --set at
2254
+ # all the command is the form, and every secret is offered.
2255
+ offered = not set_value
2233
2256
  for field, body in cast("dict[str, Any]", schema.get("properties") or {}).items():
2234
2257
  if field in config:
2235
2258
  continue
@@ -2241,6 +2264,8 @@ def connection_create(
2241
2264
  if not interactive:
2242
2265
  continue
2243
2266
  if _is_secret_field(body):
2267
+ if not offered and field not in required:
2268
+ continue
2244
2269
  value = typer.prompt(f"{field}", hide_input=True, default="", show_default=False)
2245
2270
  if value:
2246
2271
  config[field] = value
@@ -3,7 +3,7 @@
3
3
  import os
4
4
  import shutil
5
5
  import sys
6
- from collections.abc import Sequence
6
+ from collections.abc import Callable, Sequence
7
7
  from datetime import UTC, datetime, timedelta
8
8
  from importlib.metadata import PackageNotFoundError, version
9
9
  from pathlib import Path
@@ -1076,8 +1076,15 @@ async def _dev(
1076
1076
  install_signal_handlers(worker)
1077
1077
  server_config = uvicorn.Config(build_app(), host=host, port=port, log_config=None)
1078
1078
  api = uvicorn.Server(server_config)
1079
+
1080
+ def shut_down() -> None:
1081
+ """Ask for the shutdown SIGTERM asks for: the worker drains, and serve returns."""
1082
+ worker.request_stop()
1083
+ api.should_exit = True
1084
+
1079
1085
  worker_task = asyncio.create_task(worker.run())
1080
1086
  ready = asyncio.create_task(_announce_ready(api))
1087
+ orphaned = asyncio.create_task(watch_parent(os.getppid, shut_down))
1081
1088
  seeding = (
1082
1089
  asyncio.create_task(_seed(api, local_url(host, port), bearer, seed, installed)) if seed or installed else None
1083
1090
  )
@@ -1085,6 +1092,7 @@ async def _dev(
1085
1092
  await api.serve()
1086
1093
  finally:
1087
1094
  ready.cancel()
1095
+ orphaned.cancel()
1088
1096
  if seeding is not None:
1089
1097
  seeding.cancel()
1090
1098
  worker.request_stop()
@@ -1107,6 +1115,30 @@ async def _announce_ready(api: "uvicorn.Server") -> None:
1107
1115
  emit(make("process", at=datetime.now(UTC), message="ready", process="dev"))
1108
1116
 
1109
1117
 
1118
+ #: How often the instance looks at whether the process that started it is still there.
1119
+ PARENT_POLL_SECONDS = 1.0
1120
+
1121
+
1122
+ async def watch_parent(
1123
+ parent: Callable[[], int], stop: Callable[[], None], *, every: float = PARENT_POLL_SECONDS
1124
+ ) -> None:
1125
+ """Shut the instance down once the process that started it is gone.
1126
+
1127
+ ``uv run dg dev`` forwards SIGTERM to the instance, but a wrapper that was killed outright
1128
+ cannot, and the instance it leaves behind goes on claiming runs against the same state.
1129
+ macOS has no PR_SET_PDEATHSIG, so the parent pid is polled: a pid that changed means this
1130
+ process was reparented to init. A dev instance therefore does not outlive the process that
1131
+ started it, backgrounding it past the shell that launched it included.
1132
+ """
1133
+ import asyncio
1134
+
1135
+ started = parent()
1136
+ while parent() == started:
1137
+ await asyncio.sleep(every)
1138
+ emit(make("process", at=datetime.now(UTC), message="parent gone", process="dev", parent=started))
1139
+ stop()
1140
+
1141
+
1110
1142
  async def _seed(
1111
1143
  api: "uvicorn.Server",
1112
1144
  url: str,
@@ -16,6 +16,7 @@ from pydantic import BaseModel
16
16
  from rich.console import Console, Group, RenderableType
17
17
  from rich.markup import escape
18
18
  from rich.table import Table
19
+ from rich.text import Text
19
20
 
20
21
  from dirigent_client.schemas.common import Problem
21
22
  from dirigent_core.protocol import Format, Record, as_json, make
@@ -500,16 +501,26 @@ def elapsed(value: object) -> str:
500
501
  return f"{int(hours)}h{int(minutes)}m"
501
502
 
502
503
 
504
+ def titled(title: str, built: Table) -> RenderableType:
505
+ """Put a title above a table, on a line of its own.
506
+
507
+ Rich folds a table's own title to the width of the table, which the widest row sets, so a
508
+ title carrying a 36-character run id breaks in half wherever the rows are narrower than
509
+ it. Above the table the title is folded only by the console.
510
+ """
511
+ return Group(Text.from_markup(title, style="table.title"), built)
512
+
513
+
503
514
  def build_table(title: str, columns: Sequence[str], rows: Iterable[Sequence[str]]) -> RenderableType:
504
515
  """Build one rich table, or the quiet line that stands in for an empty one."""
505
- built = Table(title=title, show_header=True, header_style="bold", title_justify="left")
516
+ built = Table(show_header=True, header_style="bold")
506
517
  for column in columns:
507
518
  built.add_column(column)
508
519
  count = 0
509
520
  for row in rows:
510
521
  built.add_row(*row)
511
522
  count += 1
512
- return built if count else f"[dim]{title}: nothing to show.[/]"
523
+ return titled(title, built) if count else f"[dim]{title}: nothing to show.[/]"
513
524
 
514
525
 
515
526
  def table(title: str, columns: Sequence[str], rows: Iterable[Sequence[str]]) -> None:
@@ -518,13 +529,13 @@ def table(title: str, columns: Sequence[str], rows: Iterable[Sequence[str]]) ->
518
529
 
519
530
 
520
531
  def build_fields(title: str, values: Mapping[str, Any]) -> RenderableType:
521
- """Build one record as a two-column table."""
522
- built = Table(title=title, show_header=False, box=None, title_justify="left", padding=(0, 2, 0, 0))
532
+ """Build one record as a two-column table under its title."""
533
+ built = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
523
534
  built.add_column(style="bold")
524
535
  built.add_column()
525
536
  for name, value in values.items():
526
537
  built.add_row(name, styled(value))
527
- return built
538
+ return titled(title, built)
528
539
 
529
540
 
530
541
  def fields(title: str, values: Mapping[str, Any]) -> None:
@@ -821,14 +821,18 @@ ROOT_IGNORE_TEMPLATE = """\
821
821
  .venv/
822
822
  __pycache__/
823
823
 
824
- # This instance's token, or the stack's key and first password. Never shared.
824
+ # This instance's token and key, or the stack's key and first password. Never shared.
825
825
  .env
826
826
  """
827
827
 
828
- TOKEN_ENV_TEMPLATE = """\
828
+ INSTANCE_ENV_TEMPLATE = """\
829
829
  # The token dg init minted for this instance's first admin. The local profile in
830
830
  # .dirigent/profiles.yaml reads it from here when the shell does not export it.
831
831
  DG_TOKEN=__TOKEN__
832
+
833
+ # The key this instance seals connection secrets with, read by every command run in this
834
+ # directory. What is stored under it cannot be opened under another one.
835
+ DIRIGENT_SECRET_KEY=__SECRET_KEY__
832
836
  """
833
837
 
834
838
  PYPROJECT_TEMPLATE = """\
@@ -923,9 +927,10 @@ def scaffold(directory: Path, choices: InitChoices, *, version: str = "0.0.0") -
923
927
  return Scaffolded(files=written, skipped=skipped)
924
928
 
925
929
 
926
- def write_token_env(directory: Path, token: str) -> Path:
927
- """Write the ``.env`` holding a new instance's token, readable by its owner alone."""
928
- return _write(directory / ".env", TOKEN_ENV_TEMPLATE.replace("__TOKEN__", token), mode=0o600)
930
+ def write_instance_env(directory: Path, token: str) -> Path:
931
+ """Write the ``.env`` holding a new instance's token and key, readable by its owner alone."""
932
+ document = INSTANCE_ENV_TEMPLATE.replace("__TOKEN__", token).replace("__SECRET_KEY__", _an_instance_key())
933
+ return _write(directory / ".env", document, mode=0o600)
929
934
 
930
935
 
931
936
  def _record(path: Path, content: str, written: list[Path], skipped: list[Path]) -> None:
@@ -953,7 +958,7 @@ def _merge_ignore(path: Path, content: str, written: list[Path], skipped: list[P
953
958
 
954
959
 
955
960
  def _an_instance_key() -> str:
956
- """Generate the key the stack seals connection secrets with."""
961
+ """Generate the key an instance seals connection secrets with."""
957
962
  return generate_key()
958
963
 
959
964
 
@@ -316,7 +316,9 @@ def _initialised(record: Record) -> RenderableType | None:
316
316
  "\n [bold]uv run dg examples list --starter[/]"
317
317
  "\n [bold]uv run dg pipeline new <starter>[/]"
318
318
  "\n [bold]uv run dg apply[/]"
319
- "\n\n[dim]No DIRIGENT_SECRET_KEY is set, so a connection carrying a credential cannot be stored until it is.[/]"
319
+ "\n\n[dim]DIRIGENT_SECRET_KEY is in .env beside it, and every command run in"
320
+ "\nthis directory reads it: it is what connection secrets are sealed with, and"
321
+ "\nunder another key the instance cannot open what it stored.[/]"
320
322
  )
321
323
  return Group(*parts)
322
324
 
File without changes
File without changes