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/files.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Layer: config file(s).
|
|
2
|
+
|
|
3
|
+
Supports the pattern of a project-local file (in the current working
|
|
4
|
+
directory) overriding a user-global one (which in turn overrides an
|
|
5
|
+
optional, opt-in system-wide one), any number of sibling
|
|
6
|
+
"auxiliary" files picked up automatically, and a two-level
|
|
7
|
+
``[PARENT]``/``[PARENT.CHILD]`` table selection within each file --
|
|
8
|
+
plus resolving which table a bare positional "shorthand" argument (a
|
|
9
|
+
deck name, a profile name, whatever your app calls it) maps to, by
|
|
10
|
+
peeking at the config files themselves.
|
|
11
|
+
|
|
12
|
+
None of this cares what your app is called or what its settings are:
|
|
13
|
+
callers pass in the paths, the ``defaults`` dict (used only to know
|
|
14
|
+
which keys are worth pulling out of a TOML table), and the already-
|
|
15
|
+
decided ``table_path``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import tomllib
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigFileError(ValueError):
|
|
25
|
+
"""A config file exists but couldn't be parsed."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_raw_toml(path: Path | None) -> dict[str, Any]:
|
|
29
|
+
"""The raw, unfiltered TOML content of ``path``, or ``{}`` if
|
|
30
|
+
``path`` is ``None`` (that source disabled) or doesn't exist.
|
|
31
|
+
Raises :class:`ConfigFileError` on a malformed file.
|
|
32
|
+
"""
|
|
33
|
+
if path is None or not path.is_file():
|
|
34
|
+
return {}
|
|
35
|
+
with path.open("rb") as fh:
|
|
36
|
+
try:
|
|
37
|
+
return tomllib.load(fh)
|
|
38
|
+
except tomllib.TOMLDecodeError as exc:
|
|
39
|
+
raise ConfigFileError(f"{path}: {exc}") from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def table_exists(raw: Mapping[str, Any], table_path: list[str]) -> bool:
|
|
43
|
+
"""Whether ``raw`` has a table at ``table_path`` (e.g. ``["a", "b"]``
|
|
44
|
+
for ``[a.b]``) -- as opposed to that path being absent, or present
|
|
45
|
+
but holding something other than a table (a bare key/value).
|
|
46
|
+
"""
|
|
47
|
+
node: Any = raw
|
|
48
|
+
for part in table_path:
|
|
49
|
+
if not isinstance(node, dict) or part not in node:
|
|
50
|
+
return False
|
|
51
|
+
node = node[part]
|
|
52
|
+
return isinstance(node, dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cwd_aux_config_paths(
|
|
56
|
+
cwd_path: Path | None, pattern: str | None = ".config.*.toml"
|
|
57
|
+
) -> list[Path]:
|
|
58
|
+
"""Any sibling files matching ``pattern`` alongside ``cwd_path``
|
|
59
|
+
(its own e.g. ``.config.toml``) -- e.g. ``.config.filters.toml`` --
|
|
60
|
+
sorted by name for a deterministic merge order. This is what lets
|
|
61
|
+
settings that don't belong in the main config file (a pile of
|
|
62
|
+
generated, self-contained tables, say) live in a file of their own
|
|
63
|
+
instead, while still being picked up automatically.
|
|
64
|
+
|
|
65
|
+
``cwd_path=None`` (that source disabled) means no directory to
|
|
66
|
+
search alongside, and ``pattern=None`` turns the sibling search off
|
|
67
|
+
while leaving ``cwd_path`` itself alone; either way this returns
|
|
68
|
+
``[]``.
|
|
69
|
+
"""
|
|
70
|
+
if cwd_path is None or pattern is None:
|
|
71
|
+
return []
|
|
72
|
+
directory = cwd_path.parent if str(cwd_path.parent) else Path()
|
|
73
|
+
if not directory.is_dir():
|
|
74
|
+
return []
|
|
75
|
+
return sorted(p for p in directory.glob(pattern) if p != cwd_path and p.is_file())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config_file(
|
|
79
|
+
path: Path | None, defaults: Mapping[str, Any], table_path: list[str]
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Load one config file's settings from the selected TOML table(s).
|
|
82
|
+
|
|
83
|
+
With a single-element ``table_path`` (``[PARENT]``), this just
|
|
84
|
+
reads that top-level table. With a two-element ``[PARENT, CHILD]``
|
|
85
|
+
``table_path``, ``[PARENT]`` supplies shared settings and the
|
|
86
|
+
nested ``[PARENT.CHILD]`` table overrides them -- so several
|
|
87
|
+
profiles in one config file can share most settings and only
|
|
88
|
+
override what differs, e.g. ``[japanese]`` holding defaults common
|
|
89
|
+
to a deck, with ``[japanese.verbs]`` and ``[japanese.vocab]`` each
|
|
90
|
+
layering their own overrides on top.
|
|
91
|
+
|
|
92
|
+
Only keys already present in ``defaults`` are pulled out -- an
|
|
93
|
+
unrelated key sitting in the same table is ignored here, same as
|
|
94
|
+
everywhere else in conclude's "only keys a caster/default knows
|
|
95
|
+
about matter" convention.
|
|
96
|
+
|
|
97
|
+
``path=None`` (that source disabled), a missing file, or a file
|
|
98
|
+
with no matching table, all simply contribute nothing (same "no
|
|
99
|
+
opinion" convention as every other layer). Raises
|
|
100
|
+
:class:`ConfigFileError` on a malformed file.
|
|
101
|
+
"""
|
|
102
|
+
if path is None or not path.is_file():
|
|
103
|
+
return {}
|
|
104
|
+
raw = load_raw_toml(path)
|
|
105
|
+
|
|
106
|
+
parent = raw.get(table_path[0], {})
|
|
107
|
+
if not isinstance(parent, dict):
|
|
108
|
+
return {}
|
|
109
|
+
merged = {key: parent[key] for key in defaults if key in parent}
|
|
110
|
+
|
|
111
|
+
if len(table_path) == 2:
|
|
112
|
+
child = parent.get(table_path[1], {})
|
|
113
|
+
if isinstance(child, dict):
|
|
114
|
+
merged.update({key: child[key] for key in defaults if key in child})
|
|
115
|
+
|
|
116
|
+
return merged
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_config_files(
|
|
120
|
+
home_path: Path | None,
|
|
121
|
+
cwd_path: Path | None,
|
|
122
|
+
defaults: Mapping[str, Any],
|
|
123
|
+
table_path: list[str],
|
|
124
|
+
aux_pattern: str | None = ".config.*.toml",
|
|
125
|
+
*,
|
|
126
|
+
system_path: Path | None = None,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Merge every config-file location, lowest priority first.
|
|
129
|
+
|
|
130
|
+
``system_path`` (the system-wide file, e.g. ``/etc/<app>/config.toml``)
|
|
131
|
+
is the lowest-priority file of all: ``home_path`` overrides it on
|
|
132
|
+
any key it sets, and so does everything after that. It is opt-in
|
|
133
|
+
at this level too -- ``None`` (the default) means no system file is
|
|
134
|
+
read.
|
|
135
|
+
|
|
136
|
+
``cwd_path`` (project-local) is checked -- and wins on any key it
|
|
137
|
+
sets -- ahead of ``home_path`` (the user's global config file), so
|
|
138
|
+
a project directory can override the user's defaults just by
|
|
139
|
+
having its own local config file. Any files matching
|
|
140
|
+
``aux_pattern`` alongside ``cwd_path`` (see
|
|
141
|
+
:func:`cwd_aux_config_paths`) are merged in last, in sorted-by-name
|
|
142
|
+
order, each winning over anything before it -- so, on the rare
|
|
143
|
+
occasion a key is set in more than one of these files, the more
|
|
144
|
+
specifically-named one wins over the plain project-local one.
|
|
145
|
+
|
|
146
|
+
Any path can be ``None`` to disable that one source entirely
|
|
147
|
+
(dropping it out of the merge, same as it never existing) --
|
|
148
|
+
``cwd_path=None`` also disables the aux-file search, since there's
|
|
149
|
+
no longer a directory to search alongside; ``aux_pattern=None``
|
|
150
|
+
disables just the aux-file search.
|
|
151
|
+
"""
|
|
152
|
+
merged: dict[str, Any] = {}
|
|
153
|
+
merged.update(load_config_file(system_path, defaults, table_path))
|
|
154
|
+
merged.update(load_config_file(home_path, defaults, table_path))
|
|
155
|
+
merged.update(load_config_file(cwd_path, defaults, table_path))
|
|
156
|
+
for aux_path in cwd_aux_config_paths(cwd_path, aux_pattern):
|
|
157
|
+
merged.update(load_config_file(aux_path, defaults, table_path))
|
|
158
|
+
return merged
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_config_table(value: str | None, default_table: str) -> list[str]:
|
|
162
|
+
"""``"PARENT"`` or ``"PARENT.CHILD"`` -> ``["PARENT"]`` or
|
|
163
|
+
``["PARENT", "CHILD"]``. ``None``/``""`` (no explicit table
|
|
164
|
+
selection given at all) means ``[default_table]``.
|
|
165
|
+
|
|
166
|
+
A stray extra dot -- ``"a..b"``, ``"a."``, ``".a"`` -- is rejected
|
|
167
|
+
with ``ValueError`` rather than silently dropped: each of those
|
|
168
|
+
reads as a plausible typo (a doubled separator, a trailing/leading
|
|
169
|
+
one) for a table name the person actually meant to type
|
|
170
|
+
correctly, not as a third way to spell ``"a.b"``/``"a"``/``"a"``.
|
|
171
|
+
"""
|
|
172
|
+
if not value:
|
|
173
|
+
return [default_table]
|
|
174
|
+
parts = value.split(".")
|
|
175
|
+
if len(parts) > 2 or any(not part for part in parts):
|
|
176
|
+
raise ValueError(f"config table must be PARENT or PARENT.CHILD, got {value!r}")
|
|
177
|
+
return parts
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _resolve_shorthand_table_path(
|
|
181
|
+
shorthand: str,
|
|
182
|
+
default_table: str,
|
|
183
|
+
home_path: Path | None,
|
|
184
|
+
cwd_path: Path | None,
|
|
185
|
+
aux_pattern: str | None,
|
|
186
|
+
system_path: Path | None = None,
|
|
187
|
+
) -> list[str]:
|
|
188
|
+
"""Figure out which config table a bare positional shorthand value
|
|
189
|
+
(e.g. a deck name typed as ``myapp CARDS``) maps to, by peeking at
|
|
190
|
+
the config files themselves: a same-named top-level ``[SHORTHAND]``
|
|
191
|
+
table takes priority, as if that table had been selected
|
|
192
|
+
explicitly. Failing that, a nested ``[default_table.SHORTHAND]``
|
|
193
|
+
(inheriting whatever's shared in ``[default_table]``). Failing that
|
|
194
|
+
too, just ``[default_table]``, same as no shorthand at all.
|
|
195
|
+
|
|
196
|
+
Checked across ``cwd_path``, ``home_path``, ``system_path``, and
|
|
197
|
+
any auxiliary files alongside ``cwd_path`` as a simple union (any one of them having
|
|
198
|
+
the table counts) -- the usual value-priority between all of these
|
|
199
|
+
is unaffected, since that's still handled entirely by
|
|
200
|
+
:func:`load_config_files` once this returns which table(s) to read.
|
|
201
|
+
A ``None`` path (that source disabled) simply contributes nothing
|
|
202
|
+
to the union, same as everywhere else here.
|
|
203
|
+
"""
|
|
204
|
+
raws = [load_raw_toml(cwd_path), load_raw_toml(home_path), load_raw_toml(system_path)]
|
|
205
|
+
raws.extend(load_raw_toml(path) for path in cwd_aux_config_paths(cwd_path, aux_pattern))
|
|
206
|
+
if any(table_exists(raw, [shorthand]) for raw in raws):
|
|
207
|
+
return [shorthand]
|
|
208
|
+
if any(table_exists(raw, [default_table, shorthand]) for raw in raws):
|
|
209
|
+
return [default_table, shorthand]
|
|
210
|
+
return [default_table]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def resolve_config_table(
|
|
214
|
+
*,
|
|
215
|
+
config_value: str | None,
|
|
216
|
+
shorthand_value: str | None,
|
|
217
|
+
default_table: str,
|
|
218
|
+
home_path: Path | None,
|
|
219
|
+
cwd_path: Path | None,
|
|
220
|
+
aux_pattern: str | None = ".config.*.toml",
|
|
221
|
+
system_path: Path | None = None,
|
|
222
|
+
) -> list[str]:
|
|
223
|
+
"""Figure out which TOML table(s) to read config files from.
|
|
224
|
+
|
|
225
|
+
``config_value`` -- your app's already-resolved "--config, or its
|
|
226
|
+
env var" value, CLI winning -- always wins outright when given:
|
|
227
|
+
this part can't itself be layered through the normal config-file
|
|
228
|
+
mechanism (a config file can't tell us which table of itself to
|
|
229
|
+
read).
|
|
230
|
+
|
|
231
|
+
Otherwise, ``shorthand_value`` (a bare positional argument like a
|
|
232
|
+
deck name) picks the table by looking the name up in the config
|
|
233
|
+
files themselves -- see :func:`_resolve_shorthand_table_path`.
|
|
234
|
+
Pass ``shorthand_value=None`` if your app has no such shorthand.
|
|
235
|
+
|
|
236
|
+
Falls back to ``[default_table]`` when neither is given.
|
|
237
|
+
``home_path``/``cwd_path``/``system_path`` can each be ``None`` to
|
|
238
|
+
disable that source (the shorthand lookup then simply finds
|
|
239
|
+
nothing there).
|
|
240
|
+
"""
|
|
241
|
+
if config_value:
|
|
242
|
+
return parse_config_table(config_value, default_table)
|
|
243
|
+
if shorthand_value:
|
|
244
|
+
return _resolve_shorthand_table_path(
|
|
245
|
+
shorthand_value, default_table, home_path, cwd_path, aux_pattern, system_path
|
|
246
|
+
)
|
|
247
|
+
return [default_table]
|
conclude/formatters.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Formatting a resolved value back into a CLI-flag token -- the
|
|
2
|
+
inverse of casting. Used by :meth:`conclude.App.format_invocation` to
|
|
3
|
+
render a standalone command line that reproduces a resolved settings
|
|
4
|
+
dict, with no env vars or config files needed to get back to the same
|
|
5
|
+
result.
|
|
6
|
+
|
|
7
|
+
Each formatter returns one of three things for a given value:
|
|
8
|
+
|
|
9
|
+
- a plain string -- the flag's value, already shell-quoted, to render
|
|
10
|
+
as ``--flag=<string>``
|
|
11
|
+
- ``""`` (the empty string) -- render the flag bare, with no ``=value``
|
|
12
|
+
at all (used for a boolean that's on)
|
|
13
|
+
- ``None`` -- don't render this flag at all
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import shlex
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from typing import Any, TypeAlias
|
|
19
|
+
|
|
20
|
+
from conclude.infer import Opt
|
|
21
|
+
|
|
22
|
+
Formatter: TypeAlias = Callable[[Any], str | None]
|
|
23
|
+
"""A function taking a resolved value and returning the CLI-token text
|
|
24
|
+
to render it as (see the module docstring above for the three possible
|
|
25
|
+
shapes) -- what :func:`infer_formatter` picks one of, and what an
|
|
26
|
+
override in ``formatters=``/``overrides=`` is expected to be.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_bool(value: bool) -> str | None:
|
|
31
|
+
"""``True`` -> the bare flag; ``False`` -> not rendered at all.
|
|
32
|
+
|
|
33
|
+
This matches :meth:`conclude.App.add_arguments`'s inferred
|
|
34
|
+
``store_true``-only CLI flags for a bool setting: there's no
|
|
35
|
+
inferred CLI syntax to explicitly turn a bool back to ``False``,
|
|
36
|
+
so a setting whose *default* is ``True`` can't be faithfully
|
|
37
|
+
reproduced this way -- write your own formatter (and, likely, your
|
|
38
|
+
own CLI flag) for that one instead of relying on inference.
|
|
39
|
+
"""
|
|
40
|
+
return "" if value else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_scalar(value: Any) -> str:
|
|
44
|
+
"""Any other single value -- shell-quoted ``str(value)``."""
|
|
45
|
+
return shlex.quote(str(value))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_list(value: list[Any]) -> str:
|
|
49
|
+
"""A list -- shell-quoted, comma-joined, matching the convention
|
|
50
|
+
:func:`conclude.cast_comma_list` casts back out of.
|
|
51
|
+
"""
|
|
52
|
+
return shlex.quote(",".join(str(item) for item in value))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_FORMATTERS_BY_TYPE: dict[type, Formatter] = {
|
|
56
|
+
bool: format_bool,
|
|
57
|
+
int: format_scalar,
|
|
58
|
+
float: format_scalar,
|
|
59
|
+
str: format_scalar,
|
|
60
|
+
list: format_list,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def infer_formatter(type_: type) -> Formatter:
|
|
65
|
+
"""The formatter conclude picks for a bare Python ``type_``. Raises
|
|
66
|
+
``TypeError`` for anything else -- pass an explicit formatter for
|
|
67
|
+
that one setting instead.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
return _FORMATTERS_BY_TYPE[type_]
|
|
71
|
+
except KeyError as exc:
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"no built-in formatter for type {type_!r} -- pass an explicit "
|
|
74
|
+
"formatter for this setting instead of relying on inference"
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def infer_formatters(
|
|
79
|
+
defaults: Mapping[str, Any],
|
|
80
|
+
overrides: Mapping[str, Formatter] | None = None,
|
|
81
|
+
) -> dict[str, Formatter]:
|
|
82
|
+
"""A formatter for every key in ``defaults`` -- inferred the same
|
|
83
|
+
way :func:`conclude.infer_casters` infers a caster (from an
|
|
84
|
+
:class:`conclude.Opt`'s wrapped type, or ``type(default)``), except
|
|
85
|
+
for a key present in ``overrides``, whose formatter there is used
|
|
86
|
+
outright. Pair a formatter override with any matching caster
|
|
87
|
+
override that needs one: a setting whose caster does something
|
|
88
|
+
beyond a plain type conversion (e.g. decoding backslash escapes)
|
|
89
|
+
needs a formatter that reverses that same thing, not the type's
|
|
90
|
+
default one.
|
|
91
|
+
"""
|
|
92
|
+
overrides = overrides or {}
|
|
93
|
+
formatters: dict[str, Formatter] = {}
|
|
94
|
+
for key, default in defaults.items():
|
|
95
|
+
if key in overrides:
|
|
96
|
+
formatters[key] = overrides[key]
|
|
97
|
+
continue
|
|
98
|
+
type_ = default.type if isinstance(default, Opt) else type(default)
|
|
99
|
+
formatters[key] = infer_formatter(type_)
|
|
100
|
+
return formatters
|
conclude/guard.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The gitignore guard: a check that a private, local file really is
|
|
2
|
+
private, shared by the developer config file
|
|
3
|
+
(:mod:`conclude.developer`) and, optionally, a ``.env`` file
|
|
4
|
+
(``App(dotenv_require_gitignored=True)``).
|
|
5
|
+
|
|
6
|
+
A file that beats -- or quietly backstops -- the environment must never
|
|
7
|
+
come from somewhere it shouldn't: a deployed image, a fresh clone, a CI
|
|
8
|
+
checkout. "It is inside a git working tree *and* git is ignoring it" is
|
|
9
|
+
the practical proxy for "this is somebody's private local file", and
|
|
10
|
+
it's checkable without git installed. :func:`check_guard` runs the
|
|
11
|
+
whole sequence and never raises; it reports which check stopped it, so
|
|
12
|
+
the callers can show a reason instead of leaving anyone to wonder why a
|
|
13
|
+
file has no effect.
|
|
14
|
+
|
|
15
|
+
The ignore check needs the optional ``pathspec`` package
|
|
16
|
+
(``pip install 'conclude[gitignore]'``). A missing ``pathspec`` is
|
|
17
|
+
reported as a *setup error* (:attr:`GuardResult.setup_error`) rather
|
|
18
|
+
than as an ordinary inactive file, so the callers can raise instead of
|
|
19
|
+
skipping the file quietly.
|
|
20
|
+
|
|
21
|
+
Caveats of the ignore check, all of which fail closed (the file stays
|
|
22
|
+
inactive) except the last: the global ``core.excludesFile`` is not
|
|
23
|
+
consulted; and the check is pattern matching, not git's index, so a
|
|
24
|
+
*tracked* file that matches an ignore pattern (``git add -f``) still
|
|
25
|
+
counts as ignored, even though git itself would not call it so.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
_KILL_SWITCH_VALUES = frozenset({"0", "off", "false", "no"})
|
|
35
|
+
|
|
36
|
+
_PATHSPEC_HINT = (
|
|
37
|
+
"the 'pathspec' package is required to verify the file is gitignored -- "
|
|
38
|
+
"pip install 'conclude[gitignore]'"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class GuardResult:
|
|
44
|
+
"""The outcome of :func:`check_guard`."""
|
|
45
|
+
|
|
46
|
+
active: bool
|
|
47
|
+
"""Every check passed: the file exists, is in a git tree, and is
|
|
48
|
+
ignored by it (and no kill switch is set)."""
|
|
49
|
+
reason: str | None = None
|
|
50
|
+
"""Why not, in one short phrase, when ``active`` is ``False``."""
|
|
51
|
+
setup_error: bool = False
|
|
52
|
+
"""``True`` only when ``pathspec`` is missing, so the ignore check
|
|
53
|
+
couldn't run -- a tooling problem the caller should raise for, not a
|
|
54
|
+
state to skip quietly."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_guard(
|
|
58
|
+
path: Path,
|
|
59
|
+
*,
|
|
60
|
+
kill_switch_var: str | None = None,
|
|
61
|
+
environ: Mapping[str, str] | None = None,
|
|
62
|
+
escape_hint: str | None = None,
|
|
63
|
+
) -> GuardResult:
|
|
64
|
+
"""Whether ``path`` may be used: not switched off, existing, inside
|
|
65
|
+
a git working tree, and ignored by it. Never raises. Checks run in
|
|
66
|
+
that order and the first failure is the reason returned.
|
|
67
|
+
|
|
68
|
+
``kill_switch_var`` names an environment variable that, set to
|
|
69
|
+
``off``/``0``/``false``/``no``, deactivates the file (read from
|
|
70
|
+
``environ``, default ``os.environ``, and only ever from the real
|
|
71
|
+
environment -- it is a control knob, not a setting, so no ``.env``
|
|
72
|
+
file or config layer can carry it). It is checked first, so it also
|
|
73
|
+
avoids the ``pathspec`` requirement. ``escape_hint`` completes the
|
|
74
|
+
missing-``pathspec`` message with the way out; by default that is
|
|
75
|
+
the kill switch, if there is one.
|
|
76
|
+
"""
|
|
77
|
+
environ = os.environ if environ is None else environ
|
|
78
|
+
if kill_switch_var and environ.get(kill_switch_var, "").strip().lower() in _KILL_SWITCH_VALUES:
|
|
79
|
+
return GuardResult(
|
|
80
|
+
False, f"disabled by {kill_switch_var}={environ[kill_switch_var].strip()}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if not path.is_file():
|
|
84
|
+
return GuardResult(False, "file not found")
|
|
85
|
+
|
|
86
|
+
absolute = Path(os.path.abspath(path))
|
|
87
|
+
root = _find_git_root(absolute.parent)
|
|
88
|
+
if root is None:
|
|
89
|
+
return GuardResult(False, "not inside a git working tree")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
ignored = matches_git_ignore_rules(root, absolute)
|
|
93
|
+
except ImportError:
|
|
94
|
+
if escape_hint is None and kill_switch_var:
|
|
95
|
+
escape_hint = f"or set {kill_switch_var}=off to skip the file"
|
|
96
|
+
hint = _PATHSPEC_HINT + (f" ({escape_hint})" if escape_hint else "")
|
|
97
|
+
return GuardResult(False, hint, setup_error=True)
|
|
98
|
+
if not ignored:
|
|
99
|
+
return GuardResult(False, "not covered by .gitignore")
|
|
100
|
+
return GuardResult(True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _find_git_root(start: Path) -> Path | None:
|
|
104
|
+
"""The nearest directory at or above ``start`` containing ``.git``
|
|
105
|
+
(a directory, or a file for a worktree/submodule).
|
|
106
|
+
"""
|
|
107
|
+
for directory in (start, *start.parents):
|
|
108
|
+
if (directory / ".git").exists():
|
|
109
|
+
return directory
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def matches_git_ignore_rules(root: Path, target: Path) -> bool:
|
|
114
|
+
"""Whether the working tree's ignore rules match ``target`` (an
|
|
115
|
+
absolute path inside the working tree at ``root``).
|
|
116
|
+
|
|
117
|
+
Pure Python: it evaluates the rules itself and never runs ``git``,
|
|
118
|
+
so it answers "would these rules ignore this path?", not "does git
|
|
119
|
+
say it is ignored?" -- the two can differ (see the module docstring:
|
|
120
|
+
tracked files, and the global ``core.excludesFile``). The rules are
|
|
121
|
+
the ``.gitignore`` files from ``root`` down to the target's
|
|
122
|
+
directory plus ``root/.git/info/exclude``; nearer ``.gitignore``
|
|
123
|
+
files override farther ones, the last matching pattern in a file
|
|
124
|
+
wins, and an ignored parent directory ignores everything beneath
|
|
125
|
+
it, as :manpage:`gitignore(5)` describes. Raises ``ImportError`` if
|
|
126
|
+
the optional ``pathspec`` package isn't installed.
|
|
127
|
+
"""
|
|
128
|
+
import pathspec
|
|
129
|
+
|
|
130
|
+
parts = target.relative_to(root).parts
|
|
131
|
+
exclude_lines = _read_lines(root / ".git" / "info" / "exclude")
|
|
132
|
+
specs: dict[tuple[str, ...], Any] = {}
|
|
133
|
+
|
|
134
|
+
def spec_for(directory_parts: tuple[str, ...]) -> Any:
|
|
135
|
+
if directory_parts not in specs:
|
|
136
|
+
lines = _read_lines(root.joinpath(*directory_parts, ".gitignore"))
|
|
137
|
+
specs[directory_parts] = pathspec.GitIgnoreSpec.from_lines(lines) if lines else None
|
|
138
|
+
return specs[directory_parts]
|
|
139
|
+
|
|
140
|
+
exclude_spec = pathspec.GitIgnoreSpec.from_lines(exclude_lines) if exclude_lines else None
|
|
141
|
+
|
|
142
|
+
def ignored(path_parts: tuple[str, ...], is_dir: bool) -> bool:
|
|
143
|
+
# Nearest .gitignore first; the first file with any matching
|
|
144
|
+
# pattern (ignore *or* negation) decides.
|
|
145
|
+
for depth in range(len(path_parts) - 1, -1, -1):
|
|
146
|
+
spec = spec_for(path_parts[:depth])
|
|
147
|
+
if spec is None:
|
|
148
|
+
continue
|
|
149
|
+
relative = "/".join(path_parts[depth:]) + ("/" if is_dir else "")
|
|
150
|
+
include = spec.check_file(relative).include
|
|
151
|
+
if include is not None:
|
|
152
|
+
return bool(include)
|
|
153
|
+
if exclude_spec is not None:
|
|
154
|
+
relative = "/".join(path_parts) + ("/" if is_dir else "")
|
|
155
|
+
include = exclude_spec.check_file(relative).include
|
|
156
|
+
if include is not None:
|
|
157
|
+
return bool(include)
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
# Git never descends into an ignored directory, so nothing inside
|
|
161
|
+
# one can be re-included: check every ancestor directory first.
|
|
162
|
+
for depth in range(1, len(parts)):
|
|
163
|
+
if ignored(parts[:depth], is_dir=True):
|
|
164
|
+
return True
|
|
165
|
+
return ignored(parts, is_dir=False)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_lines(path: Path) -> list[str]:
|
|
169
|
+
try:
|
|
170
|
+
return path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
171
|
+
except OSError:
|
|
172
|
+
return []
|
conclude/infer.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Inferring a caster -- and, for a setting whose default is ``None``,
|
|
2
|
+
the real type a provided value should become -- from a defaults dict
|
|
3
|
+
alone, so a setting only has to be written once (as its default
|
|
4
|
+
value) rather than also needing a matching entry in some separate
|
|
5
|
+
casters dict.
|
|
6
|
+
|
|
7
|
+
A default that's already a concrete ``bool``/``int``/``float``/``str``/
|
|
8
|
+
``list`` value carries its own type directly (``type(default)``). A
|
|
9
|
+
default of plain ``None`` doesn't -- and "optional, unset by default"
|
|
10
|
+
is the single most common shape a setting takes -- so wrap it with
|
|
11
|
+
:func:`opt` instead of writing a bare ``None``: ``opt(str)`` still
|
|
12
|
+
*is* ``None`` at runtime (as the actual default, and as the "not
|
|
13
|
+
provided in this layer" sentinel every other layer already follows),
|
|
14
|
+
it just also remembers what type a provided value should cast to.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, TypeAlias
|
|
20
|
+
|
|
21
|
+
from conclude.casters import (
|
|
22
|
+
cast_bool,
|
|
23
|
+
cast_comma_list,
|
|
24
|
+
cast_float_or_none,
|
|
25
|
+
cast_int_or_none,
|
|
26
|
+
cast_str_or_none,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
Caster: TypeAlias = Callable[[Any], Any]
|
|
30
|
+
"""A function taking a raw value from any layer (a CLI string, an env
|
|
31
|
+
var string, a TOML-native value, ...) and returning the properly-typed
|
|
32
|
+
Python value it represents -- what :func:`infer_caster` picks one of,
|
|
33
|
+
and what an override in ``casters=``/``overrides=`` is expected to be.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
_CASTERS_BY_TYPE: dict[type, Caster] = {
|
|
37
|
+
bool: cast_bool,
|
|
38
|
+
int: cast_int_or_none,
|
|
39
|
+
float: cast_float_or_none,
|
|
40
|
+
str: cast_str_or_none,
|
|
41
|
+
list: cast_comma_list,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Opt:
|
|
47
|
+
"""A typed placeholder for "no default value, but here's the type
|
|
48
|
+
a provided value should cast to" -- see :func:`opt`. Not meant to
|
|
49
|
+
be constructed directly; use ``opt(type_)``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
type: type
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return f"opt({self.type.__name__})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def opt(type_: type) -> Opt:
|
|
59
|
+
"""The default to write for an optional setting of type ``type_``
|
|
60
|
+
-- e.g. ``opt(str)`` for a setting that starts out unset but,
|
|
61
|
+
once a value reaches it from any layer, should be a string; or
|
|
62
|
+
``opt(list)`` for one that becomes a comma-separated list. Behaves
|
|
63
|
+
as plain ``None`` everywhere a default is actually *used* (the
|
|
64
|
+
defaults layer itself, and the "not provided" check every other
|
|
65
|
+
layer follows) -- this wrapper only exists so caster inference has
|
|
66
|
+
a type to work from.
|
|
67
|
+
"""
|
|
68
|
+
return Opt(type_)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def infer_caster(type_: type) -> Caster:
|
|
72
|
+
"""The caster conclude picks for a bare Python ``type_``
|
|
73
|
+
(``bool``/``int``/``float``/``str``/``list``). Raises ``TypeError``
|
|
74
|
+
for anything else -- write your own caster and pass it via an
|
|
75
|
+
``overrides``/``casters`` mapping for that one setting instead of
|
|
76
|
+
relying on inference.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
return _CASTERS_BY_TYPE[type_]
|
|
80
|
+
except KeyError as exc:
|
|
81
|
+
raise TypeError(
|
|
82
|
+
f"no built-in caster for type {type_!r} -- pass an explicit "
|
|
83
|
+
"caster for this setting instead of relying on inference"
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def effective_defaults(defaults: Mapping[str, Any]) -> dict[str, Any]:
|
|
88
|
+
"""``defaults``, with every :class:`Opt` placeholder unwrapped to
|
|
89
|
+
the ``None`` it actually stands for -- this is what the defaults
|
|
90
|
+
*layer* of the merge should use as real values.
|
|
91
|
+
"""
|
|
92
|
+
return {key: (None if isinstance(value, Opt) else value) for key, value in defaults.items()}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def infer_casters(
|
|
96
|
+
defaults: Mapping[str, Any],
|
|
97
|
+
overrides: Mapping[str, Caster] | None = None,
|
|
98
|
+
) -> dict[str, Caster]:
|
|
99
|
+
"""A caster for every key in ``defaults``: inferred from each
|
|
100
|
+
default's type (:class:`Opt`'s wrapped type, or ``type(default)``
|
|
101
|
+
for anything else) -- except for a key present in ``overrides``,
|
|
102
|
+
whose caster there is used outright instead. Reach for an override
|
|
103
|
+
when inference alone isn't enough for that one setting: a caster
|
|
104
|
+
that needs custom error-message wording, or a string setting (like
|
|
105
|
+
a separator) where an explicit empty value is meaningfully
|
|
106
|
+
different from "not provided" (see :func:`conclude.cast_escaped_str`).
|
|
107
|
+
"""
|
|
108
|
+
overrides = overrides or {}
|
|
109
|
+
casters: dict[str, Caster] = {}
|
|
110
|
+
for key, default in defaults.items():
|
|
111
|
+
if key in overrides:
|
|
112
|
+
casters[key] = overrides[key]
|
|
113
|
+
continue
|
|
114
|
+
type_ = default.type if isinstance(default, Opt) else type(default)
|
|
115
|
+
casters[key] = infer_caster(type_)
|
|
116
|
+
return casters
|