dirigent-cli 0.9.2__tar.gz → 0.9.3__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.3}/PKG-INFO +2 -1
  2. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/pyproject.toml +2 -1
  3. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/pyproject.toml.orig +2 -1
  4. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/commands.py +10 -2
  5. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/context.py +15 -1
  6. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/profiles.py +19 -1
  7. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/project.py +13 -8
  8. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/summaries.py +9 -4
  9. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/LICENSE +0 -0
  10. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/README.md +0 -0
  11. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/__init__.py +0 -0
  12. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/aliases.py +0 -0
  13. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/formatters.py +0 -0
  14. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/graph.py +0 -0
  15. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/health.py +0 -0
  16. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/local.py +0 -0
  17. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/main.py +0 -0
  18. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/output.py +0 -0
  19. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/params.py +0 -0
  20. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/py.typed +0 -0
  21. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/reaper.py +0 -0
  22. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/scaffold.py +0 -0
  23. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/schemas.py +0 -0
  24. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/sources.py +0 -0
  25. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/stream.py +0 -0
  26. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  27. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  28. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  29. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  30. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  31. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/src/dirigent_cli/timing.py +0 -0
  32. {dirigent_cli-0.9.2 → dirigent_cli-0.9.3}/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.3
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.3"
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.3"
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
 
@@ -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 --keep-state 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
@@ -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__"
@@ -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 --keep-state[/] [dim]# plain dg dev empties .dirigent/state first[/]"
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