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/__init__.py +189 -0
- conclude/app.py +819 -0
- conclude/casters.py +262 -0
- conclude/developer.py +215 -0
- conclude/env.py +176 -0
- conclude/files.py +247 -0
- conclude/formatters.py +100 -0
- conclude/guard.py +172 -0
- conclude/infer.py +116 -0
- conclude/merge.py +58 -0
- conclude/naming.py +58 -0
- conclude/paths.py +26 -0
- conclude/py.typed +0 -0
- conclude/templates.py +83 -0
- conclude/tomlwrite.py +29 -0
- conclude-1.0.0.dist-info/METADATA +260 -0
- conclude-1.0.0.dist-info/RECORD +19 -0
- conclude-1.0.0.dist-info/WHEEL +4 -0
- conclude-1.0.0.dist-info/licenses/LICENSE +7 -0
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
|