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 +49 -0
- xtr_dotenv/bundle/__init__.py +8 -0
- xtr_dotenv/bundle/dotenv_bundle.py +52 -0
- xtr_dotenv/bundle/dotenv_config.py +61 -0
- xtr_dotenv/command/__init__.py +8 -0
- xtr_dotenv/command/debug_dotenv_command.py +90 -0
- xtr_dotenv/command/dotenv_dump_command.py +70 -0
- xtr_dotenv/dotenv.py +467 -0
- xtr_dotenv/dotenv_settings.py +67 -0
- xtr_dotenv/dotenv_settings_source.py +95 -0
- xtr_dotenv/exception/__init__.py +21 -0
- xtr_dotenv/exception/dotenv_error.py +14 -0
- xtr_dotenv/exception/format_error.py +35 -0
- xtr_dotenv/exception/path_error.py +29 -0
- xtr_dotenv/exception/variable_circular_reference_error.py +26 -0
- xtr_dotenv/py.typed +0 -0
- xtr_dotenv-1.2.0.dist-info/METADATA +248 -0
- xtr_dotenv-1.2.0.dist-info/RECORD +20 -0
- xtr_dotenv-1.2.0.dist-info/WHEEL +4 -0
- xtr_dotenv-1.2.0.dist-info/licenses/LICENSE +21 -0
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,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}}
|