dirigent-cli 0.9.2__tar.gz → 0.9.4__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 (32) hide show
  1. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/PKG-INFO +2 -1
  2. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/pyproject.toml +2 -1
  3. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/pyproject.toml.orig +2 -1
  4. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/commands.py +11 -3
  5. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/context.py +15 -1
  6. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/main.py +7 -10
  7. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/profiles.py +19 -1
  8. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/project.py +15 -10
  9. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/summaries.py +9 -4
  10. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/LICENSE +0 -0
  11. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/README.md +0 -0
  12. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/__init__.py +0 -0
  13. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/aliases.py +0 -0
  14. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/formatters.py +0 -0
  15. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/graph.py +0 -0
  16. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/health.py +0 -0
  17. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/local.py +0 -0
  18. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/output.py +0 -0
  19. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/params.py +0 -0
  20. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/py.typed +0 -0
  21. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/reaper.py +0 -0
  22. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/scaffold.py +0 -0
  23. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/schemas.py +0 -0
  24. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/sources.py +0 -0
  25. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/stream.py +0 -0
  26. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  27. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  28. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  29. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  30. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  31. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/src/dirigent_cli/timing.py +0 -0
  32. {dirigent_cli-0.9.2 → dirigent_cli-0.9.4}/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.9.2
3
+ Version: 0.9.4
4
4
  Summary: The dirigent command line interface (dirigent / dg).
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -11,6 +11,7 @@ Requires-Dist: dirigent-core
11
11
  Requires-Dist: dirigent-plugin
12
12
  Requires-Dist: dirigent-server
13
13
  Requires-Dist: httpx2>=2.12.0
14
+ Requires-Dist: python-dotenv>=1.1.0
14
15
  Requires-Dist: pyyaml>=6.0.3
15
16
  Requires-Dist: rich>=15.0.0
16
17
  Requires-Dist: tomli-w>=1.2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.9.2"
3
+ version = "0.9.4"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "dirigent-plugin",
15
15
  "dirigent-server",
16
16
  "httpx2>=2.12.0",
17
+ "python-dotenv>=1.1.0",
17
18
  "pyyaml>=6.0.3",
18
19
  "rich>=15.0.0",
19
20
  "tomli-w>=1.2.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.9.2"
3
+ version = "0.9.4"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "dirigent-plugin",
15
15
  "dirigent-server",
16
16
  "httpx2>=2.12.0",
17
+ "python-dotenv>=1.1.0",
17
18
  "pyyaml>=6.0.3",
18
19
  "rich>=15.0.0",
19
20
  "tomli-w>=1.2.0",
@@ -56,7 +56,14 @@ from dirigent_cli.output import (
56
56
  table,
57
57
  )
58
58
  from dirigent_cli.params import ParamError, build_params
59
- from dirigent_cli.project import COMPOSE_TEMPLATE_NAME, ProjectError, check_template, find_project, scaffold
59
+ from dirigent_cli.project import (
60
+ COMPOSE_TEMPLATE_NAME,
61
+ ProjectError,
62
+ check_template,
63
+ find_project,
64
+ scaffold,
65
+ write_token_env,
66
+ )
60
67
  from dirigent_cli.scaffold import ScaffoldedRecord, ScaffoldError, ScaffoldRecord, scaffold_pack
61
68
  from dirigent_cli.sources import Document, SourceError, looks_like_a_document, read_document, read_path
62
69
  from dirigent_cli.stream import Sink, track_steps, use_scratch_prefix
@@ -546,6 +553,7 @@ def init_command(
546
553
  migrated = migrations.head_revision(settings) or "none"
547
554
  migrations.upgrade("head", settings)
548
555
  token = asyncio.run(first_admin(settings, admin, secret))
556
+ env_file = write_token_env(root, token)
549
557
  emit_fact(
550
558
  "instance.initialised",
551
559
  message="initialised",
@@ -556,7 +564,7 @@ def init_command(
556
564
  admin=admin,
557
565
  token=token,
558
566
  version=version,
559
- files=[_within(path, root) for path in made.files],
567
+ files=[_within(path, root) for path in [*made.files, env_file]],
560
568
  **({"skipped": [_within(path, root) for path in made.skipped]} if made.skipped else {}),
561
569
  )
562
570
 
@@ -600,7 +608,7 @@ def _refuse_an_existing_instance(root: Path) -> None:
600
608
  _init_fail(
601
609
  f"{existing} already exists, so this directory holds an instance already",
602
610
  problems=[
603
- "dg dev --keep-state starts it",
611
+ "dg dev starts it",
604
612
  "dg db upgrade brings its schema forward",
605
613
  "dg init --documents-only scaffolds documents beside it",
606
614
  ],
@@ -4,6 +4,7 @@ import logging
4
4
  import os
5
5
  from collections.abc import Awaitable
6
6
  from functools import cached_property
7
+ from urllib.parse import urlsplit
7
8
 
8
9
  import typer
9
10
  from pydantic import BaseModel, ConfigDict
@@ -17,7 +18,7 @@ from dirigent_cli.output import (
17
18
  )
18
19
  from dirigent_cli.profiles import Endpoint, ProfileError, resolve_endpoint
19
20
  from dirigent_cli.stream import ansi
20
- from dirigent_client import BlockingDirigent, DirigentError
21
+ from dirigent_client import BlockingDirigent, DirigentError, TransportError
21
22
  from dirigent_core.logging import NOISY_LOGGERS, configure_logging
22
23
  from dirigent_core.protocol import Format
23
24
 
@@ -115,6 +116,17 @@ def state_of(ctx: typer.Context) -> CliState:
115
116
  return found if found is not None else CliState()
116
117
 
117
118
 
119
+ #: What a refused connection to a loopback address adds: the local instance is not running.
120
+ LOCAL_INSTANCE_HINT = "nothing is listening there; uv run dg dev in the project starts its instance"
121
+
122
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
123
+
124
+
125
+ def _is_local(url: str | None) -> bool:
126
+ """Whether a URL addresses this machine, where a refused connection means nothing is running."""
127
+ return bool(url) and (urlsplit(url or "").hostname or "") in LOOPBACK_HOSTS
128
+
129
+
118
130
  class Session(BlockingDirigent):
119
131
  """The SDK, driven from the CLI's synchronous commands."""
120
132
 
@@ -125,6 +137,8 @@ class Session(BlockingDirigent):
125
137
  except DirigentError as error:
126
138
  if error.problem is not None:
127
139
  write_refusal(error.problem)
140
+ elif isinstance(error, TransportError) and _is_local(error.url):
141
+ refuse(error.message, problems=[LOCAL_INSTANCE_HINT])
128
142
  else:
129
143
  refuse(error.message, status=error.status or 1)
130
144
  raise typer.Exit(code=1) from error
@@ -810,18 +810,16 @@ def dev(
810
810
  bool,
811
811
  typer.Option(
812
812
  "--wipe-state/--keep-state",
813
- help="Delete the state directory before starting, or keep what the last run left.",
813
+ help="Delete the state directory before starting, instead of running the instance that is there.",
814
814
  ),
815
- ] = True,
815
+ ] = False,
816
816
  ) -> None:
817
817
  """Run the API and an embedded worker in one process, on SQLite, with no dependencies.
818
818
 
819
819
  The database and the artifacts live in .dirigent/state under the working directory, so
820
820
  starting this somewhere else means a different instance, with none of the same runs.
821
-
822
- That directory is deleted on every start unless --keep-state says otherwise: SQLite here
823
- is a development artifact, and a database left over from an older schema answers
824
- strangely rather than failing. Only a directory dirigent named itself is removed.
821
+ An instance that is there, made by dg init or by an earlier start, is the one that runs;
822
+ --wipe-state deletes it first, and only a directory dirigent named itself is removed.
825
823
  """
826
824
  import asyncio
827
825
  import os
@@ -879,10 +877,10 @@ def clear_state(settings: Settings) -> Path | None:
879
877
 
880
878
 
881
879
  def state_cleared(directory: Path) -> Record:
882
- """Build the record saying the state went, and how to have kept it.
880
+ """Build the record saying the state went, as --wipe-state asked.
883
881
 
884
- Emitted only when something was actually deleted, so a first start on an empty machine
885
- stays quiet.
882
+ Emitted only when something was actually deleted, so a wipe of an empty machine stays
883
+ quiet.
886
884
  """
887
885
  return make(
888
886
  "process",
@@ -890,7 +888,6 @@ def state_cleared(directory: Path) -> Record:
890
888
  message="state cleared",
891
889
  process="dev",
892
890
  state=str(directory),
893
- hint="--keep-state keeps it",
894
891
  )
895
892
 
896
893
 
@@ -12,12 +12,14 @@ from pathlib import Path
12
12
  from typing import Any, Final, cast
13
13
 
14
14
  import yaml
15
+ from dotenv import dotenv_values
15
16
  from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
16
17
 
17
18
  from dirigent_client import API_PREFIX
18
19
  from dirigent_common import EntityName
19
20
 
20
21
  PROJECT_PROFILES: Final = Path(".dirigent") / "profiles.yaml"
22
+ PROJECT_ENV_FILE: Final = ".env"
21
23
  USER_PROFILES: Final = Path.home() / ".config" / "dirigent" / "profiles.yaml"
22
24
 
23
25
  URL_ENV: Final = "DG_URL"
@@ -109,6 +111,21 @@ class ProfileStore(BaseModel):
109
111
  profiles: dict[EntityName, Profile] = Field(default_factory=dict[str, Profile])
110
112
  path: Path | None = None
111
113
 
114
+ @property
115
+ def env_file(self) -> Path | None:
116
+ """The ``.env`` at the root of the project this file belongs to, or none for the user file."""
117
+ if self.path is None or self.path.parent.name != PROJECT_PROFILES.parent.name:
118
+ return None
119
+ return self.path.parent.parent / PROJECT_ENV_FILE
120
+
121
+ def environment(self, environ: Mapping[str, str]) -> dict[str, str]:
122
+ """The environment a profile resolves in: the process's, with the project's ``.env`` filling gaps."""
123
+ path = self.env_file
124
+ if path is None or not path.is_file():
125
+ return dict(environ)
126
+ from_file = {key: value for key, value in dotenv_values(path).items() if value is not None}
127
+ return {**from_file, **environ}
128
+
112
129
  def select(self, name: str | None) -> Profile | None:
113
130
  """Choose a profile by name, by the file's default, or by there being only one."""
114
131
  wanted = name or self.default
@@ -189,11 +206,12 @@ def resolve_endpoint(
189
206
  ) -> Endpoint:
190
207
  """Resolve the server and token: flags first, then ``DG_*``, then the selected profile.
191
208
 
209
+ A project's ``.env`` stands in for any ``DG_*`` variable the environment does not set.
192
210
  With ``needs_token`` off, a profile whose token cannot be resolved yields no token rather
193
211
  than refusing, which is what the command that mints one asks for.
194
212
  """
195
- env = environ if environ is not None else dict(os.environ)
196
213
  store = find_store(start)
214
+ env = store.environment(environ if environ is not None else os.environ)
197
215
  chosen = store.select(profile or env.get(PROFILE_ENV))
198
216
 
199
217
  if url is not None:
@@ -477,14 +477,17 @@ ROOT_IGNORE_TEMPLATE = """\
477
477
  # The environment uv sync builds from pyproject.toml.
478
478
  .venv/
479
479
  __pycache__/
480
- """
481
480
 
482
- #: Appended to the root ignore by the template whose .env holds the instance key.
483
- COMPOSE_IGNORE_TEMPLATE = """\
484
- # Holds this instance's key and its first admin's password.
481
+ # This instance's token, or the stack's key and first password. Never shared.
485
482
  .env
486
483
  """
487
484
 
485
+ TOKEN_ENV_TEMPLATE = """\
486
+ # The token dg init minted for this instance's first admin. The local profile in
487
+ # .dirigent/profiles.yaml reads it from here when the shell does not export it.
488
+ DG_TOKEN=__TOKEN__
489
+ """
490
+
488
491
  PYPROJECT_TEMPLATE = """\
489
492
  [project]
490
493
  name = "__NAME__"
@@ -516,13 +519,13 @@ __RUN__
516
519
  README_RUN: Final = {
517
520
  "basic": (
518
521
  "uv sync",
519
- "uv run dg dev --keep-state",
522
+ "uv run dg dev",
520
523
  "uv run dg apply",
521
524
  "uv run dg run hello-world --watch",
522
525
  ),
523
526
  "ci": (
524
527
  "uv sync",
525
- "uv run dg dev --keep-state",
528
+ "uv run dg dev",
526
529
  "uv run dg apply",
527
530
  "uv run dg run hello-world --watch",
528
531
  ),
@@ -585,10 +588,7 @@ def scaffold(
585
588
  _record(directory / "pyproject.toml", pyproject, written, skipped)
586
589
  readme = README_TEMPLATE.replace("__NAME__", name).replace("__RUN__", "\n".join(README_RUN[template]))
587
590
  _record(directory / "README.md", readme, written, skipped)
588
- ignore = ROOT_IGNORE_TEMPLATE
589
- if template == COMPOSE_TEMPLATE_NAME:
590
- ignore += "\n" + COMPOSE_IGNORE_TEMPLATE
591
- _merge_ignore(directory / ".gitignore", ignore, written, skipped)
591
+ _merge_ignore(directory / ".gitignore", ROOT_IGNORE_TEMPLATE, written, skipped)
592
592
  if template == "ci":
593
593
  written.append(_write(directory / ".github" / "workflows" / "dirigent.yml", WORKFLOW_TEMPLATE))
594
594
  if template == COMPOSE_TEMPLATE_NAME:
@@ -603,6 +603,11 @@ def scaffold(
603
603
  return Scaffolded(files=written, skipped=skipped)
604
604
 
605
605
 
606
+ def write_token_env(directory: Path, token: str) -> Path:
607
+ """Write the ``.env`` holding a new instance's token, readable by its owner alone."""
608
+ return _write(directory / ".env", TOKEN_ENV_TEMPLATE.replace("__TOKEN__", token), mode=0o600)
609
+
610
+
606
611
  def _record(path: Path, content: str, written: list[Path], skipped: list[Path]) -> None:
607
612
  """Write a file a project may already have of its own, leaving any existing one alone."""
608
613
  if path.exists():
@@ -285,12 +285,17 @@ def _initialised(record: Record) -> RenderableType | None:
285
285
  },
286
286
  )
287
287
  )
288
+ admin = escape(str(record.get("admin") or "admin"))
288
289
  parts.append(
289
- "\nThe token is shown once and never stored in readable form. Keep it:"
290
- f"\n [bold]export DG_TOKEN={token}[/]"
291
- "\n\nBuild the project's environment, start it, apply the example, run it:"
290
+ "\nThe instance lives in this directory. Its database is .dirigent/state, and the"
291
+ f"\ntoken of its first admin, [bold]{admin}[/], is in .env, where the local profile reads it."
292
+ "\nShown here once:"
293
+ f"\n [bold]{token}[/]"
294
+ "\n\nStart the instance in a second terminal here; it keeps running:"
292
295
  "\n [bold]uv sync[/]"
293
- "\n [bold]uv run dg dev --keep-state[/] [dim]# plain dg dev starts by emptying .dirigent/state[/]"
296
+ "\n [bold]uv run dg dev[/]"
297
+ f"\nThe UI is at http://127.0.0.1:3333, and {admin} logs in with the password you gave."
298
+ "\n\nThen apply the example here, and run it:"
294
299
  "\n [bold]uv run dg apply[/]"
295
300
  "\n [bold]uv run dg run hello-world --watch[/]"
296
301
  "\n\n[yellow]This is an instance for one person on one machine[/]: SQLite on this disk, and"
File without changes
File without changes