xtr-dotenv 1.2.0__py3-none-any.whl

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.
xtr_dotenv/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """Layered dotenv files loaded into the environment, and the same layers behind settings.
2
+
3
+ Real applications keep several ``.env`` files layered so a developer
4
+ laptop, a CI runner and a production host each end up with the settings
5
+ that suit, without any one file knowing about the other. This library is
6
+ the loader every layer runs through, plus a pydantic-settings source that
7
+ feeds a typed model from the same cascade.
8
+
9
+ The two entry points a typical application needs are:
10
+
11
+ * :class:`~xtr_dotenv.dotenv.Dotenv` — call ``Dotenv().boot_env(project_dir / ".env")``
12
+ at the process' entry point, before anything reads a settings model.
13
+ * :class:`~xtr_dotenv.dotenv_settings.DotenvSettings` — a
14
+ :class:`~pydantic_settings.BaseSettings` base that gets the layered
15
+ values *without* mutating :data:`os.environ` itself.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from importlib.metadata import PackageNotFoundError, version
21
+
22
+ from .dotenv import PATH_VAR, TRACKING_VAR, Dotenv
23
+ from .dotenv_settings import DotenvSettings
24
+ from .dotenv_settings_source import DotenvSettingsSource
25
+ from .exception import (
26
+ DotenvError,
27
+ FormatError,
28
+ PathError,
29
+ VariableCircularReferenceError,
30
+ )
31
+
32
+ try:
33
+ __version__ = version("xtr-dotenv")
34
+ except PackageNotFoundError: # pragma: no cover
35
+ # Running from a source tree with no installed metadata to read.
36
+ __version__ = "0+unknown"
37
+
38
+ __all__ = [
39
+ "PATH_VAR",
40
+ "TRACKING_VAR",
41
+ "Dotenv",
42
+ "DotenvError",
43
+ "DotenvSettings",
44
+ "DotenvSettingsSource",
45
+ "FormatError",
46
+ "PathError",
47
+ "VariableCircularReferenceError",
48
+ "__version__",
49
+ ]
@@ -0,0 +1,8 @@
1
+ """The xtr-dependency-injection bundle for xtr-dotenv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .dotenv_bundle import DotenvBundle
6
+ from .dotenv_config import DotenvConfig
7
+
8
+ __all__ = ["DotenvBundle", "DotenvConfig"]
@@ -0,0 +1,52 @@
1
+ """The xtr-dotenv bundle: exposes the dotenv commands when a console is present.
2
+
3
+ The bundle does **not** load files itself. The application is expected to
4
+ call ``Dotenv().boot_env(project_dir / ".env")`` at its entry point,
5
+ before building the kernel: the kernel and every service it creates rely
6
+ on the environment already being layered by the time they read it.
7
+
8
+ What the bundle does supply is the two commands — ``dotenv:dump`` and
9
+ ``debug:dotenv`` — that need the container's ``KernelInterface`` to know
10
+ the project directory. They are loaded only when the console bundle is
11
+ active, so a headless application still boots to zero config.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import final
17
+
18
+ from typing_extensions import override
19
+ from xtr_dependency_injection import (
20
+ Bundle,
21
+ ContainerBuilder,
22
+ ServiceConfigurator,
23
+ as_bundle,
24
+ bundle_active,
25
+ )
26
+
27
+ from .dotenv_config import DotenvConfig
28
+
29
+ __all__ = ["DotenvBundle"]
30
+
31
+
32
+ @final
33
+ @as_bundle("dotenv", config=DotenvConfig)
34
+ class DotenvBundle(Bundle[DotenvConfig]):
35
+ """Wire the dotenv commands into the container when a console is around."""
36
+
37
+ @override
38
+ def load_extension(
39
+ self,
40
+ config: DotenvConfig,
41
+ services: ServiceConfigurator,
42
+ builder: ContainerBuilder,
43
+ ) -> None:
44
+ """Load the command module only when a console bundle is active.
45
+
46
+ Loading a command module late is the pattern the messenger bundle
47
+ uses too: it keeps the console dependency out of the runtime graph
48
+ for applications that do not run one, and does no I/O at build.
49
+ """
50
+ del config
51
+ if bundle_active(builder, "console"):
52
+ services.load("xtr_dotenv.command")
@@ -0,0 +1,61 @@
1
+ """Configuration for :class:`~xtr_dotenv.bundle.dotenv_bundle.DotenvBundle`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ __all__ = ["DotenvConfig"]
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class DotenvConfig:
13
+ """How the dotenv bundle describes the layered cascade to its commands.
14
+
15
+ The bundle itself does no I/O: an application calls
16
+ ``Dotenv().boot_env(...)`` at its entry point before building the
17
+ kernel. What lives here is the description the ``dotenv:dump`` and
18
+ ``debug:dotenv`` commands read to reason about the same cascade.
19
+
20
+ Attributes:
21
+ path: The base ``.env`` path. ``%kernel.project_dir%`` and every other
22
+ parameter reference is resolved by the kernel; a relative path is
23
+ taken from the project directory.
24
+ env_key: The variable naming the active environment.
25
+ debug_key: The variable naming debug mode.
26
+ test_envs: Environments where the ``.local`` overlay is skipped.
27
+ prod_envs: Environments considered production for debug defaulting.
28
+
29
+ Raises:
30
+ ValueError: When any of ``path``, ``env_key``, ``debug_key``,
31
+ ``test_envs`` or ``prod_envs`` is empty.
32
+ """
33
+
34
+ path: str = "%kernel.project_dir%/.env"
35
+ env_key: str = "APP_ENV"
36
+ debug_key: str = "APP_DEBUG"
37
+ test_envs: tuple[str, ...] = ("test",)
38
+ prod_envs: tuple[str, ...] = ("prod",)
39
+
40
+ def __post_init__(self) -> None:
41
+ """Refuse combinations the loader would silently accept but not mean."""
42
+ if not self.path:
43
+ message = "path must not be empty"
44
+ raise ValueError(message)
45
+ if not self.env_key:
46
+ message = "env_key must not be empty"
47
+ raise ValueError(message)
48
+ if not self.debug_key:
49
+ message = "debug_key must not be empty"
50
+ raise ValueError(message)
51
+ if not self.test_envs:
52
+ message = "test_envs must not be empty"
53
+ raise ValueError(message)
54
+ if not self.prod_envs:
55
+ message = "prod_envs must not be empty"
56
+ raise ValueError(message)
57
+
58
+ def base_path(self, project_dir: Path) -> Path:
59
+ """Return :attr:`path`, taken from ``project_dir`` when it is relative."""
60
+ candidate = Path(self.path)
61
+ return candidate if candidate.is_absolute() else project_dir / candidate
@@ -0,0 +1,8 @@
1
+ """Console commands the dotenv bundle contributes when a console is active."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .debug_dotenv_command import DebugDotenvCommand
6
+ from .dotenv_dump_command import DotenvDumpCommand
7
+
8
+ __all__ = ["DebugDotenvCommand", "DotenvDumpCommand"]
@@ -0,0 +1,90 @@
1
+ """``debug:dotenv``: list the cascade files and each variable's value per file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import final
7
+
8
+ from xtr_console import ConsoleStyle, ExitCode, as_command
9
+ from xtr_dependency_injection import ( # noqa: TC002 — engine reads annotations at runtime.
10
+ Injected,
11
+ KernelInterface,
12
+ )
13
+
14
+ from xtr_dotenv.bundle.dotenv_config import ( # noqa: TC001 — engine reads annotations at runtime.
15
+ DotenvConfig,
16
+ )
17
+ from xtr_dotenv.dotenv import Dotenv
18
+ from xtr_dotenv.exception import DotenvError
19
+
20
+ __all__ = ["DebugDotenvCommand"]
21
+
22
+
23
+ @as_command("debug:dotenv")
24
+ @final
25
+ class DebugDotenvCommand:
26
+ """List every file in the cascade and each variable's value per file.
27
+
28
+ Missing files are shown too, in order, so a debugger can see exactly
29
+ what the loader would have done. ``name`` filters the value listing to
30
+ a single variable, which is what a "why is this value what it is?"
31
+ session usually needs.
32
+ """
33
+
34
+ async def __call__(
35
+ self,
36
+ io: ConsoleStyle,
37
+ kernel: Injected[KernelInterface],
38
+ config: Injected[DotenvConfig],
39
+ name: str | None = None,
40
+ ) -> int:
41
+ """Print the cascade files and (optionally) one variable across them."""
42
+ base_path = config.base_path(kernel.project_dir)
43
+ env = kernel.environment
44
+ cascade_paths = _cascade_paths(base_path, env, config.test_envs)
45
+ io.section(f"Cascade for env={env!r} at {base_path}")
46
+ io.table(
47
+ ("File", "Status"),
48
+ tuple(
49
+ (str(candidate), "loaded" if candidate.exists() else "missing")
50
+ for candidate in cascade_paths
51
+ ),
52
+ )
53
+ per_file: list[tuple[str, dict[str, str]]] = []
54
+ for candidate in cascade_paths:
55
+ if not candidate.exists():
56
+ continue
57
+ try:
58
+ loader = Dotenv(env_key=config.env_key, environ={config.env_key: env})
59
+ # Parse a single file to see what IT contributes; expansion is
60
+ # done against the env key alone so the output is per-file.
61
+ parsed = loader.parse(candidate.read_text(encoding="utf-8"), str(candidate))
62
+ except DotenvError as error:
63
+ io.error(f"{candidate}: {error}")
64
+ return ExitCode.FAILURE
65
+ per_file.append((str(candidate), parsed))
66
+ rows: list[tuple[str, str, str]] = []
67
+ for file_label, values in per_file:
68
+ for var_name, value in sorted(values.items()):
69
+ if name is not None and var_name != name:
70
+ continue
71
+ rows.append((file_label, var_name, value))
72
+ if rows:
73
+ io.table(("File", "Variable", "Value"), tuple(rows))
74
+ elif name is not None:
75
+ io.note(f"variable {name!r} not present in any cascade file")
76
+ return ExitCode.SUCCESS
77
+
78
+
79
+ def _cascade_paths(base: Path, env: str, test_envs: tuple[str, ...]) -> list[Path]:
80
+ """List the cascade files in the order the loader would try them."""
81
+ paths: list[Path] = [base]
82
+ dist = Path(f"{base}.dist")
83
+ if not base.exists() and dist.exists():
84
+ paths.append(dist)
85
+ if env not in test_envs:
86
+ paths.append(Path(f"{base}.local"))
87
+ if env != "local":
88
+ paths.append(Path(f"{base}.{env}"))
89
+ paths.append(Path(f"{base}.{env}.local"))
90
+ return paths
@@ -0,0 +1,70 @@
1
+ """``dotenv:dump``: compile the cascade for one env into ``<path>.local.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import final
8
+
9
+ from xtr_console import ConsoleStyle, ExitCode, as_command
10
+ from xtr_dependency_injection import ( # noqa: TC002 — engine reads annotations at runtime.
11
+ Injected,
12
+ KernelInterface,
13
+ )
14
+
15
+ from xtr_dotenv.bundle.dotenv_config import ( # noqa: TC001 — engine reads annotations at runtime.
16
+ DotenvConfig,
17
+ )
18
+ from xtr_dotenv.dotenv import PATH_VAR, TRACKING_VAR, Dotenv
19
+ from xtr_dotenv.exception import DotenvError
20
+
21
+ __all__ = ["DotenvDumpCommand"]
22
+
23
+
24
+ @as_command("dotenv:dump")
25
+ @final
26
+ class DotenvDumpCommand:
27
+ """Compile the layered cascade for one env into ``<path>.local.json``.
28
+
29
+ The dump is what :meth:`Dotenv.boot_env` reads for a fast start. It is
30
+ computed on a **fresh environ** carrying only the env key (never real
31
+ secrets), so the file it writes is safe to commit and travels with the
32
+ build.
33
+ """
34
+
35
+ async def __call__(
36
+ self,
37
+ io: ConsoleStyle,
38
+ kernel: Injected[KernelInterface],
39
+ config: Injected[DotenvConfig],
40
+ env: str | None = None,
41
+ ) -> int:
42
+ """Write the compiled cascade for ``env`` (default: the kernel's env)."""
43
+ target_env = env if env is not None else kernel.environment
44
+ base_path = config.base_path(kernel.project_dir)
45
+ sandbox: dict[str, str] = {config.env_key: target_env}
46
+ loader = Dotenv(env_key=config.env_key, environ=sandbox)
47
+ try:
48
+ _ = loader.load_env(
49
+ str(base_path),
50
+ default_env=target_env,
51
+ test_envs=config.test_envs,
52
+ )
53
+ except DotenvError as error:
54
+ io.error(f"cannot compile cascade: {error}")
55
+ return ExitCode.FAILURE
56
+ payload = _extract(sandbox)
57
+ dump_path = Path(f"{base_path}.local.json")
58
+ _write(dump_path, json.dumps(payload, indent=2, sort_keys=True))
59
+ io.success(f"wrote {dump_path} ({len(payload)} values)")
60
+ return ExitCode.SUCCESS
61
+
62
+
63
+ def _write(path: Path, content: str) -> None:
64
+ """Blocking write, isolated so the async ``__call__`` stays lint-clean."""
65
+ _ = path.write_text(content, encoding="utf-8")
66
+
67
+
68
+ def _extract(sandbox: dict[str, str]) -> dict[str, str]:
69
+ """Drop the internal bookkeeping variables before serialising."""
70
+ return {name: value for name, value in sandbox.items() if name not in {TRACKING_VAR, PATH_VAR}}