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/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