conclude 1.0.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.
conclude/casters.py ADDED
@@ -0,0 +1,262 @@
1
+ """Reusable casters for the common shapes a CLI config setting takes.
2
+
3
+ Every caster here follows the same convention the rest of conclude
4
+ relies on: given "no opinion" input (``None``, or ``""`` for the
5
+ string-ish ones), it returns ``None`` rather than raising, so a layer
6
+ that didn't mention a setting merges away cleanly (see
7
+ :func:`conclude.merge.resolve`). A caster is applied uniformly to every
8
+ layer -- CLI string, env var string, TOML-native value -- so e.g. the
9
+ env var string ``"true"`` and a TOML boolean ``true`` normalize to the
10
+ exact same Python value before the merge happens.
11
+
12
+ These cover the common cases; an application with a setting that needs
13
+ its own bespoke casting (a "STYLE" mini-language, say) just writes its
14
+ own caster function -- :func:`conclude.merge.resolve` takes any
15
+ ``dict[str, Callable]``, not specifically these.
16
+ """
17
+
18
+ import re
19
+ from typing import Any
20
+
21
+ _TRUE_STRINGS = frozenset({"1", "true", "yes", "on", "y"})
22
+ _FALSE_STRINGS = frozenset({"0", "false", "no", "off", "n"})
23
+
24
+ # Matches a bare integer literal with a redundant all-zero decimal
25
+ # tail ("5.0", "5.", "-3.00") -- see _try_parse_exact_int below.
26
+ _TRAILING_ZERO_DECIMAL_RE = re.compile(r"^([+-]?\d+)\.0*$")
27
+
28
+ # The exact, narrow set of backslash escapes cast_escaped_str (and
29
+ # conclude.env.load_dotenv's double-quoted values) decode -- see
30
+ # decode_backslash_escapes below for why this has to be a manual,
31
+ # character-by-character scan rather than reusing Python's own
32
+ # "unicode_escape" codec.
33
+ _BACKSLASH_ESCAPES = {
34
+ "n": "\n",
35
+ "t": "\t",
36
+ "r": "\r",
37
+ "\\": "\\",
38
+ '"': '"',
39
+ }
40
+
41
+
42
+ def decode_backslash_escapes(text: str) -> str:
43
+ """Decode just ``\\n``/``\\t``/``\\r``/``\\\\``/``\\"`` in ``text``,
44
+ leaving every other character -- including every non-ASCII one --
45
+ completely untouched, and leaving any *other* backslash sequence
46
+ (``\\x41``, ``\\u00e9``, an unrecognized ``\\q``, a trailing lone
47
+ ``\\``) exactly as written rather than guessing at it.
48
+
49
+ This is deliberately not ``text.encode("utf-8").decode("unicode_escape")``,
50
+ which looks like it does the same thing but doesn't: encoding to
51
+ UTF-8 first turns any non-ASCII character into multiple bytes, and
52
+ "unicode_escape" then decodes those bytes one at a time as if each
53
+ were its own Latin-1 character -- so e.g. ``"café"`` silently comes
54
+ back as ``"café"``, and anything outside Latin-1 (CJK text, emoji,
55
+ ...) comes back as multi-character garbage. A plain scan for
56
+ exactly the handful of escapes this is meant to support has no such
57
+ failure mode, at the cost of not supporting the rest of
58
+ "unicode_escape"'s much larger grammar (octal/hex/named escapes) --
59
+ which was never the point here (see :func:`cast_escaped_str`).
60
+ """
61
+ result: list[str] = []
62
+ i = 0
63
+ length = len(text)
64
+ while i < length:
65
+ ch = text[i]
66
+ if ch == "\\" and i + 1 < length and text[i + 1] in _BACKSLASH_ESCAPES:
67
+ result.append(_BACKSLASH_ESCAPES[text[i + 1]])
68
+ i += 2
69
+ else:
70
+ result.append(ch)
71
+ i += 1
72
+ return "".join(result)
73
+
74
+
75
+ def cast_bool(value: Any) -> bool:
76
+ """Truthy/falsy strings/numbers/bools -> bool.
77
+
78
+ Recognizes ``1``/``true``/``yes``/``on``/``y`` (case-insensitive) as
79
+ true, ``0``/``false``/``no``/``off``/``n`` as false, and an empty
80
+ string as false (the "not provided, so falsy" case a bool setting's
81
+ absence normally means). A Python ``bool`` is passed through
82
+ unchanged, so a TOML-native ``true``/``false`` is unaffected by this
83
+ string-oriented check.
84
+
85
+ Anything else raises ``ValueError`` rather than silently guessing --
86
+ a typo like ``"tru"`` should be caught, not quietly treated as
87
+ false just because it isn't in the recognized true set.
88
+ """
89
+ if isinstance(value, bool):
90
+ return value
91
+ text = str(value).strip().lower()
92
+ if text == "":
93
+ return False
94
+ if text in _TRUE_STRINGS:
95
+ return True
96
+ if text in _FALSE_STRINGS:
97
+ return False
98
+ raise ValueError(f"expected a boolean (true/false/yes/no/on/off/1/0/y/n), got {value!r}")
99
+
100
+
101
+ def _try_parse_exact_int(text: str) -> int | None:
102
+ """Parse ``text`` as an exact Python ``int`` if it's a clean
103
+ integer literal -- optionally with a redundant all-zero decimal
104
+ tail, e.g. ``"5"`` or ``"5.00"`` -- without ever routing it
105
+ through ``float()``. Returns ``None`` for anything else (a
106
+ genuinely fractional value like ``"1.9"``, scientific notation,
107
+ ``"inf"``/``"nan"``, or non-numeric text) -- callers fall back to
108
+ ``float()`` themselves for those, accepting its limits since
109
+ there's no way around them for input that isn't a bare integer to
110
+ begin with.
111
+
112
+ The reason this exists at all: a Python ``float`` only has 53 bits
113
+ of exact integer precision. Routing an already-exact integer (or a
114
+ numeral string representing one) through ``float()`` first -- the
115
+ obvious way to also accept ``"5.0"``-style input -- can silently
116
+ corrupt it once it's larger than that: ``int(float(9007199254740993))``
117
+ quietly becomes ``9007199254740992``, one off from the real value,
118
+ with no error raised anywhere. Handling the "already an exact
119
+ integer, possibly with a pointless trailing ``.0``" shape directly,
120
+ before ``float()`` ever enters the picture, avoids that entirely.
121
+ """
122
+ try:
123
+ return int(text)
124
+ except ValueError:
125
+ pass
126
+ match = _TRAILING_ZERO_DECIMAL_RE.match(text)
127
+ return int(match.group(1)) if match else None
128
+
129
+
130
+ def cast_int_or_none(value: Any, *, label: str = "a number") -> int | float | None:
131
+ """A whole or fractional number, or ``None``.
132
+
133
+ Returns an ``int`` when the value is a whole number (``"5"``,
134
+ ``5.0``) and a ``float`` otherwise (``"1.5"``), which is usually
135
+ what you want for a "seconds" or similar real-valued setting where
136
+ whole numbers should print/compare cleanly but fractions are still
137
+ allowed. Raises ``ValueError`` (message customizable via ``label``,
138
+ e.g. ``"a number of seconds"``) on anything that isn't a number at
139
+ all.
140
+
141
+ An already-``int`` value, or a numeral string like ``"5"`` or
142
+ ``"9007199254740993"``, is parsed as an exact integer directly --
143
+ see :func:`_try_parse_exact_int` -- rather than always routing
144
+ through ``float()`` first, which would silently corrupt a large
145
+ enough integer.
146
+ """
147
+ if value in (None, ""):
148
+ return None
149
+ if isinstance(value, int):
150
+ return value
151
+ if isinstance(value, float):
152
+ return int(value) if value.is_integer() else value
153
+ exact = _try_parse_exact_int(str(value).strip())
154
+ if exact is not None:
155
+ return exact
156
+ try:
157
+ as_float = float(value)
158
+ except (TypeError, ValueError) as exc:
159
+ raise ValueError(f"expected {label}, got {value!r}") from exc
160
+ return int(as_float) if as_float.is_integer() else as_float
161
+
162
+
163
+ def cast_positive_int(value: Any, *, label: str = "a positive integer") -> int | None:
164
+ """A whole number >= 1, or ``None``.
165
+
166
+ Raises ``ValueError`` (message customizable via ``label``) on
167
+ non-numeric input, on a number below 1, and -- unlike a naive
168
+ ``int(float(value))`` -- on a value that parses as a number but
169
+ isn't actually a whole one (``1.9``, which ``int()`` would
170
+ silently truncate to ``1`` rather than reject) or isn't finite
171
+ (``"inf"``/``"nan"``, which ``int()`` raises ``OverflowError``/
172
+ another ``ValueError`` internally -- both are normalized to the
173
+ one documented ``ValueError`` here, same as everything else this
174
+ rejects).
175
+
176
+ Like :func:`cast_int_or_none`, an already-``int`` value or a clean
177
+ integer-literal string never goes through ``float()`` at all, so a
178
+ large value (beyond a float's 53 bits of exact integer precision)
179
+ is never at risk of silently losing precision along the way.
180
+ """
181
+ if value in (None, ""):
182
+ return None
183
+ if isinstance(value, int):
184
+ as_int = value
185
+ elif isinstance(value, float):
186
+ if not value.is_integer():
187
+ raise ValueError(f"expected {label} (a whole number), got {value!r}")
188
+ as_int = int(value)
189
+ else:
190
+ exact = _try_parse_exact_int(str(value).strip())
191
+ if exact is not None:
192
+ as_int = exact
193
+ else:
194
+ try:
195
+ as_float = float(value)
196
+ except (TypeError, ValueError) as exc:
197
+ raise ValueError(f"expected {label}, got {value!r}") from exc
198
+ if not as_float.is_integer():
199
+ raise ValueError(f"expected {label} (a whole number), got {value!r}")
200
+ as_int = int(as_float)
201
+ if as_int < 1:
202
+ raise ValueError(f"{label} must be >= 1, got {value!r}")
203
+ return as_int
204
+
205
+
206
+ def cast_float_or_none(value: Any) -> float | None:
207
+ """A floating-point number, or ``None``. Raises ``ValueError`` on
208
+ anything that isn't numeric at all. Prefer :func:`cast_int_or_none`
209
+ for a setting that should print as a whole number when the value
210
+ given happens to be one (e.g. "5" rather than "5.0") -- this one
211
+ always returns a ``float``.
212
+ """
213
+ if value in (None, ""):
214
+ return None
215
+ try:
216
+ return float(value)
217
+ except (TypeError, ValueError) as exc:
218
+ raise ValueError(f"expected a number, got {value!r}") from exc
219
+
220
+
221
+ def cast_str_or_none(value: Any) -> str | None:
222
+ """A string, or ``None`` for "not provided" (``None`` or ``""``).
223
+
224
+ Use this for settings where an empty string never means anything
225
+ different from "unset" -- e.g. a filename or column name. For a
226
+ setting where an explicit empty string IS a meaningful, distinct
227
+ choice, use :func:`cast_escaped_str` instead.
228
+ """
229
+ if value in (None, ""):
230
+ return None
231
+ return str(value)
232
+
233
+
234
+ def cast_comma_list(value: Any) -> list[str] | None:
235
+ """Comma-separated string (or an already-a-list TOML value) ->
236
+ ``list[str]``, or ``None`` for "not provided".
237
+
238
+ Each item is stripped of surrounding whitespace; empty items
239
+ (e.g. from a trailing comma) are dropped.
240
+ """
241
+ if value in (None, ""):
242
+ return None
243
+ if isinstance(value, list):
244
+ return [str(v).strip() for v in value if str(v).strip()]
245
+ return [part.strip() for part in str(value).split(",") if part.strip()]
246
+
247
+
248
+ def cast_escaped_str(value: Any) -> str | None:
249
+ """Like :func:`cast_str_or_none`, but an explicit empty string is a
250
+ real choice (e.g. "no separator at all"), not "not provided" -- so
251
+ unlike the other string casters, ``""`` is preserved rather than
252
+ folded into ``None``. Only an actual ``None`` means "not provided".
253
+
254
+ Also decodes backslash escapes -- ``\\n``/``\\t``/``\\r``/``\\\\``/``\\"``
255
+ -- into their real characters, so a CLI value like
256
+ ``--separator='\\n\\n'`` behaves the way a user would expect; see
257
+ :func:`decode_backslash_escapes` for exactly what is and isn't
258
+ touched (non-ASCII text is always left alone).
259
+ """
260
+ if value is None:
261
+ return None
262
+ return decode_backslash_escapes(str(value))
conclude/developer.py ADDED
@@ -0,0 +1,215 @@
1
+ """Layer: the developer config file -- a private, project-local TOML
2
+ file that sits *above* the environment in the precedence chain::
3
+
4
+ defaults < system < user < project < env < developer < CLI
5
+
6
+ The motivation is a local ``DATABASE_URL`` (or any other setting a
7
+ developer wants pinned while working on one project): environment
8
+ variables are process-wide and can leak between projects through a
9
+ long-lived shell, while a file whose location the *project* decides is
10
+ scoped to exactly the project it belongs to -- so it wins over ambient
11
+ environment state. Use the CLI for a one-off override.
12
+
13
+ Where the file lives is declared in the project's ``pyproject.toml``::
14
+
15
+ [tool.conclude.developer]
16
+ config = ".conclude.local.toml"
17
+
18
+ (relative to the directory containing that ``pyproject.toml``). The
19
+ file's format follows its name: a ``.toml`` file uses the same ``[app]``
20
+ table layout as every other config file, and anything else -- say
21
+ ``.env.local`` -- is dotenv, keyed by the app's environment variable
22
+ names, so the one private file can also serve docker compose, direnv or
23
+ an IDE run configuration. It is deliberately hard to activate by accident, because a file
24
+ that beats the environment must never come from somewhere it shouldn't
25
+ -- a deployed image, a fresh clone, a CI checkout. It only applies when
26
+ *all* of these hold, and otherwise it is skipped quietly -- no error, no
27
+ warning, though :meth:`conclude.App.describe_sources` always says which
28
+ case it is:
29
+
30
+ 1. the app opted in (``App(pyproject_path=AUTO)``),
31
+ 2. ``pyproject.toml`` sets ``tool.conclude.developer.config``,
32
+ 3. the ``<APP>_DEVELOPER_CONFIG`` kill switch is not set to ``off``,
33
+ 4. the file exists,
34
+ 5. it is inside a git working tree, and
35
+ 6. it is covered by that tree's ignore rules (``.gitignore`` files and
36
+ ``.git/info/exclude``) -- i.e. private by construction. Checks 3-6
37
+ are the shared :mod:`conclude.guard`, which needs the optional
38
+ ``pathspec`` package (``pip install 'conclude[gitignore]'``).
39
+
40
+ Two things are deliberately *not* quiet, because they are setup errors
41
+ rather than ordinary states, and hiding them would leave a developer
42
+ wondering why their file has no effect: a missing ``pathspec`` when the
43
+ ignore check is actually needed raises ``ImportError`` (the kill switch,
44
+ checked first, avoids it), and an active file that isn't valid TOML
45
+ raises :class:`conclude.ConfigFileError`. :func:`developer_status` and
46
+ ``describe_sources()`` never raise; they just report.
47
+
48
+ The ignore check's caveats (the global ``core.excludesFile`` isn't
49
+ consulted; pattern matching, not git's index) are in
50
+ :mod:`conclude.guard`.
51
+ """
52
+
53
+ import enum
54
+ import tomllib
55
+ from collections.abc import Mapping
56
+ from dataclasses import dataclass
57
+ from pathlib import Path
58
+ from typing import Any, Literal
59
+
60
+ from conclude.env import load_env
61
+ from conclude.files import ConfigFileError, load_config_file
62
+ from conclude.guard import check_guard
63
+
64
+
65
+ class DeveloperState(enum.Enum):
66
+ """Where the developer config layer stands -- the four cases
67
+ :meth:`conclude.App.describe_sources` tells apart.
68
+ """
69
+
70
+ NOT_OPTED_IN = "not opted in"
71
+ """The app never asked for a developer layer at all."""
72
+
73
+ NOT_CONFIGURED = "not configured"
74
+ """Opted in, but the project's ``pyproject.toml`` names no file."""
75
+
76
+ INACTIVE = "configured, inactive"
77
+ """A file is named, but a safety condition isn't met (see
78
+ :attr:`DeveloperStatus.reason`); the layer contributes nothing."""
79
+
80
+ ACTIVE = "configured, active"
81
+ """The file is named, exists, and is gitignored; it is read."""
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class DeveloperStatus:
86
+ """The state of the developer layer, and -- for any state but
87
+ ``NOT_OPTED_IN`` -- why. Never raised or logged: it's information
88
+ for :meth:`conclude.App.describe_sources` and for an app's own
89
+ diagnostics (e.g. a ``--diagnose`` flag).
90
+ """
91
+
92
+ state: DeveloperState
93
+ path: Path | None = None
94
+ """The developer file's path, once ``pyproject.toml`` names one."""
95
+ reason: str | None = None
96
+ """Why ``NOT_CONFIGURED``/``INACTIVE``, in one short phrase."""
97
+ setup_error: bool = False
98
+ """``True`` only for a setup error rather than a normal state -- the
99
+ ``pathspec`` package is missing, so the ignore check can't run.
100
+ :func:`load_developer_config` raises for it instead of quietly
101
+ skipping the file, since silently ignoring a *tooling* problem
102
+ would leave a developer wondering why their file has no effect."""
103
+
104
+ @property
105
+ def active(self) -> bool:
106
+ return self.state is DeveloperState.ACTIVE
107
+
108
+ @property
109
+ def format(self) -> Literal["toml", "dotenv"] | None:
110
+ """How the file is parsed, decided by its name alone: ``"toml"``
111
+ for a name ending in ``.toml`` (any case), ``"dotenv"`` for
112
+ anything else (``.env.local``, ``.env``, no extension at all),
113
+ and ``None`` until ``pyproject.toml`` names a file.
114
+ """
115
+ if self.path is None:
116
+ return None
117
+ return "toml" if self.path.name.lower().endswith(".toml") else "dotenv"
118
+
119
+ def __str__(self) -> str:
120
+ if self.state is DeveloperState.NOT_OPTED_IN:
121
+ return str(self.state.value)
122
+ if self.state is DeveloperState.NOT_CONFIGURED:
123
+ return f"{self.state.value} ({self.reason})" if self.reason else str(self.state.value)
124
+ detail = f" ({self.reason})" if self.state is DeveloperState.INACTIVE else ""
125
+ return f"{self.path} -- {self.state.value}{detail}"
126
+
127
+
128
+ def developer_status(
129
+ pyproject_path: Path | None,
130
+ *,
131
+ kill_switch_var: str | None = None,
132
+ environ: Mapping[str, str] | None = None,
133
+ ) -> DeveloperStatus:
134
+ """Work out the state of the developer layer. Never raises.
135
+
136
+ ``pyproject_path`` is the ``pyproject.toml`` to read
137
+ ``tool.conclude.developer.config`` from, or ``None`` if the app
138
+ didn't opt in. ``kill_switch_var`` names an environment variable
139
+ that, set to ``off``/``0``/``false``/``no``, deactivates a
140
+ configured file (read from ``environ``, default ``os.environ``,
141
+ and only ever from the real environment -- it is a control knob, not
142
+ a setting, so no ``.env`` file or config layer can carry it).
143
+ """
144
+ if pyproject_path is None:
145
+ return DeveloperStatus(DeveloperState.NOT_OPTED_IN)
146
+
147
+ configured = _read_configured_path(pyproject_path)
148
+ if isinstance(configured, str):
149
+ return DeveloperStatus(DeveloperState.NOT_CONFIGURED, reason=configured)
150
+ path = configured
151
+
152
+ result = check_guard(path, kill_switch_var=kill_switch_var, environ=environ)
153
+ state = DeveloperState.ACTIVE if result.active else DeveloperState.INACTIVE
154
+ return DeveloperStatus(state, path, result.reason, setup_error=result.setup_error)
155
+
156
+
157
+ def load_developer_config(
158
+ status: DeveloperStatus,
159
+ defaults: Mapping[str, Any],
160
+ table_path: list[str],
161
+ env_vars: Mapping[str, str] | None = None,
162
+ ) -> dict[str, Any]:
163
+ """The developer layer's settings from the file ``status`` says is
164
+ active, or ``{}`` for any state but ``ACTIVE``.
165
+
166
+ A ``.toml`` file is read like any config file: the selected TOML
167
+ table(s), only keys in ``defaults`` (see
168
+ :func:`conclude.load_config_file`), and an active file that isn't
169
+ valid TOML raises :class:`conclude.ConfigFileError` (a developer
170
+ editing their own file should be told). Any other file is dotenv
171
+ (see :func:`conclude.load_dotenv`): ``env_vars`` -- your
172
+ ``{setting: ENV_VAR_NAME}`` mapping, required for this format --
173
+ says which variables in it are settings, and ``table_path`` is
174
+ ignored because dotenv has no tables; the values are raw strings,
175
+ cast later like any other layer. Either way, ``ImportError`` is
176
+ raised if ``pathspec`` is missing (see
177
+ :attr:`DeveloperStatus.setup_error`).
178
+ """
179
+ if status.setup_error:
180
+ raise ImportError(f"developer config file {status.path}: {status.reason}")
181
+ if not status.active:
182
+ return {}
183
+ if status.format == "dotenv":
184
+ if env_vars is None:
185
+ raise ValueError(
186
+ f"developer config file {status.path} is dotenv (only .toml files are "
187
+ "TOML), so reading it needs env_vars= to map its variables to settings"
188
+ )
189
+ return load_env(env_vars, {}, dotenv_path=status.path)
190
+ try:
191
+ return load_config_file(status.path, defaults, table_path)
192
+ except ConfigFileError as exc:
193
+ raise ConfigFileError(f"developer config file {exc}") from exc
194
+
195
+
196
+ def _read_configured_path(pyproject_path: Path) -> Path | str:
197
+ """The developer file path named by ``pyproject_path`` (relative
198
+ to its directory), or a short reason string if none is.
199
+ """
200
+ try:
201
+ data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
202
+ except FileNotFoundError:
203
+ return f"no {pyproject_path}"
204
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
205
+ return f"{pyproject_path} is not readable TOML"
206
+
207
+ node: Any = data
208
+ for part in ("tool", "conclude", "developer"):
209
+ node = node.get(part) if isinstance(node, dict) else None
210
+ value = node.get("config") if isinstance(node, dict) else None
211
+ if value is None:
212
+ return f"no tool.conclude.developer.config in {pyproject_path}"
213
+ if not isinstance(value, str) or not value.strip():
214
+ return "tool.conclude.developer.config must be a non-empty string"
215
+ return pyproject_path.parent / value.strip()
conclude/env.py ADDED
@@ -0,0 +1,176 @@
1
+ """Layer: environment variables -- the real process environment, with
2
+ an optional ``.env`` file as a fallback for anything not actually set
3
+ there.
4
+
5
+ By default a ``.env`` file is read as-is, so one can be committed with
6
+ non-secret local defaults. When it holds private values instead, pass
7
+ ``require_gitignored=True`` (``App(dotenv_require_gitignored=True)``):
8
+ the file is then read only if it passes the same gitignore guard as the
9
+ developer config file -- exists, inside a git working tree, and ignored
10
+ by it (see :mod:`conclude.guard`) -- and is otherwise skipped quietly,
11
+ with :func:`dotenv_status` saying why. A missing ``pathspec`` (the
12
+ optional ``conclude[gitignore]`` extra the check needs) is the one
13
+ loud case: it raises ``ImportError``.
14
+ """
15
+
16
+ import enum
17
+ import os
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from conclude.casters import decode_backslash_escapes
24
+ from conclude.guard import check_guard
25
+
26
+
27
+ class DotenvState(enum.Enum):
28
+ """Where the ``.env`` fallback stands."""
29
+
30
+ DISABLED = "disabled"
31
+ """No ``.env`` path at all (the default)."""
32
+
33
+ ACTIVE = "active"
34
+ """The file will be read (if it exists) -- and, when a gitignore
35
+ guard was requested, it passed."""
36
+
37
+ INACTIVE = "inactive"
38
+ """A guard was requested and the file failed it (see
39
+ :attr:`DotenvStatus.reason`); nothing is read from it."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class DotenvStatus:
44
+ """The state of the ``.env`` fallback, and why. Never raised or
45
+ logged: information for :meth:`conclude.App.describe_sources` and
46
+ an app's own diagnostics.
47
+ """
48
+
49
+ state: DotenvState
50
+ path: Path | None = None
51
+ reason: str | None = None
52
+ """Why ``INACTIVE``, in one short phrase."""
53
+ guarded: bool = False
54
+ """Whether a gitignore guard was requested."""
55
+ setup_error: bool = False
56
+ """``True`` only when the guard needed ``pathspec`` and it is
57
+ missing: :func:`load_env` raises ``ImportError`` for it instead of
58
+ quietly skipping the file."""
59
+
60
+ @property
61
+ def active(self) -> bool:
62
+ return self.state is DotenvState.ACTIVE
63
+
64
+ def __str__(self) -> str:
65
+ if self.state is DotenvState.DISABLED:
66
+ return "disabled"
67
+ if self.state is DotenvState.ACTIVE:
68
+ return f"{self.path} -- active (gitignored)" if self.guarded else str(self.path)
69
+ return f"{self.path} -- inactive ({self.reason})"
70
+
71
+
72
+ def dotenv_status(dotenv_path: Path | None, *, require_gitignored: bool = False) -> DotenvStatus:
73
+ """Work out the state of the ``.env`` fallback. Never raises.
74
+
75
+ ``dotenv_path=None`` is ``DISABLED``. Otherwise, without
76
+ ``require_gitignored`` the file is ``ACTIVE`` whether or not it
77
+ exists yet (exactly the pre-guard behavior); with it, the file must
78
+ also pass :func:`conclude.guard.check_guard`.
79
+ """
80
+ if dotenv_path is None:
81
+ return DotenvStatus(DotenvState.DISABLED)
82
+ if not require_gitignored:
83
+ return DotenvStatus(DotenvState.ACTIVE, dotenv_path)
84
+ result = check_guard(dotenv_path, escape_hint="or stop requiring it to be gitignored")
85
+ state = DotenvState.ACTIVE if result.active else DotenvState.INACTIVE
86
+ return DotenvStatus(
87
+ state, dotenv_path, result.reason, guarded=True, setup_error=result.setup_error
88
+ )
89
+
90
+
91
+ def load_dotenv(path: Path) -> dict[str, str]:
92
+ """Parse a ``.env``-style file into a plain ``{NAME: value}`` dict
93
+ of strings, or ``{}`` if ``path`` doesn't exist.
94
+
95
+ Supported syntax, one variable per line: ``NAME=value``, an
96
+ optional leading ``export `` (ignored), blank lines and lines
97
+ starting with ``#`` skipped, and a value optionally wrapped in
98
+ matching quotes -- single-quoted is taken completely literally;
99
+ double-quoted additionally decodes backslash escapes (``\\n``,
100
+ ``\\t``, ``\\r``, ``\\\\``, ``\\"`` -- see
101
+ :func:`conclude.casters.decode_backslash_escapes`, which non-ASCII
102
+ characters pass through unchanged), matching common ``.env``
103
+ convention.
104
+
105
+ Deliberately not supported: multi-line values, inline comments
106
+ after a value on the same line, and ``${OTHER_VAR}``-style
107
+ interpolation -- keeping the parser small and its behavior easy to
108
+ predict beats covering every corner of every ``.env`` dialect in
109
+ the wild. Write a fuller ``.env`` parser yourself and pass its
110
+ result as ``environ``/merge it in ahead of time if you need one of
111
+ these.
112
+ """
113
+ if not path.is_file():
114
+ return {}
115
+ result: dict[str, str] = {}
116
+ for raw_line in path.read_text().splitlines():
117
+ line = raw_line.strip()
118
+ if not line or line.startswith("#"):
119
+ continue
120
+ line = line.removeprefix("export ").strip()
121
+ if "=" not in line:
122
+ continue
123
+ key, _, value = line.partition("=")
124
+ key = key.strip()
125
+ value = value.strip()
126
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
127
+ quote, value = value[0], value[1:-1]
128
+ if quote == '"':
129
+ value = decode_backslash_escapes(value)
130
+ result[key] = value
131
+ return result
132
+
133
+
134
+ def load_env(
135
+ env_vars: Mapping[str, str],
136
+ environ: Mapping[str, str] | None = None,
137
+ *,
138
+ dotenv_path: Path | None = None,
139
+ require_gitignored: bool = False,
140
+ ) -> dict[str, Any]:
141
+ """Read the environment-variable layer.
142
+
143
+ ``env_vars`` maps your setting name -> the environment variable
144
+ name that carries it (e.g. ``{"filename": "FLASHCARDS_FILENAME"}``).
145
+ Only variables actually present end up in the result, same "no
146
+ opinion" convention every other layer follows.
147
+
148
+ ``environ`` (default: ``os.environ``) is always checked first and
149
+ always wins: pass ``dotenv_path`` to also fall back to a ``.env``
150
+ file (see :func:`load_dotenv`) for any variable ``environ`` itself
151
+ doesn't already set -- a real, actually-exported environment
152
+ variable overriding a stale ``.env`` file is the conventional
153
+ behavior every dotenv-style tool follows, and it's what lets a
154
+ ``.env`` file be safely committed for local development without
155
+ silently overriding whatever a deployment environment injects for
156
+ real.
157
+
158
+ With ``require_gitignored=True`` the ``.env`` file is only used if
159
+ it passes the gitignore guard (see :func:`dotenv_status`), and a
160
+ missing ``pathspec`` raises ``ImportError`` rather than quietly
161
+ dropping the file.
162
+ """
163
+ environ = os.environ if environ is None else environ
164
+ merged: dict[str, str] = {}
165
+ if dotenv_path is not None:
166
+ status = dotenv_status(dotenv_path, require_gitignored=require_gitignored)
167
+ if status.setup_error:
168
+ raise ImportError(f".env file {status.path}: {status.reason}")
169
+ if status.active:
170
+ merged = load_dotenv(dotenv_path)
171
+ merged.update(environ)
172
+ result: dict[str, Any] = {}
173
+ for key, var_name in env_vars.items():
174
+ if var_name in merged:
175
+ result[key] = merged[var_name]
176
+ return result