onefig 0.1.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.
- onefig/__init__.py +8 -0
- onefig/_cli.py +41 -0
- onefig/_cli_entry.py +84 -0
- onefig/_completion.py +295 -0
- onefig/_diff.py +185 -0
- onefig/_env.py +63 -0
- onefig/_format.py +138 -0
- onefig/_git.py +27 -0
- onefig/_help.py +293 -0
- onefig/_loader.py +151 -0
- onefig/_overrides.py +89 -0
- onefig/model.py +689 -0
- onefig/py.typed +0 -0
- onefig-0.1.0.dist-info/METADATA +700 -0
- onefig-0.1.0.dist-info/RECORD +18 -0
- onefig-0.1.0.dist-info/WHEEL +4 -0
- onefig-0.1.0.dist-info/entry_points.txt +2 -0
- onefig-0.1.0.dist-info/licenses/LICENSE +21 -0
onefig/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from onefig._diff import MISSING
|
|
4
|
+
from onefig._format import flatten, format_tree, unflatten
|
|
5
|
+
from onefig.model import ConfigModel
|
|
6
|
+
|
|
7
|
+
__all__ = ["ConfigModel", "MISSING", "flatten", "format_tree", "unflatten"]
|
|
8
|
+
__version__ = "0.1.0"
|
onefig/_cli.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_overrides(tokens: list[str]) -> dict[str, Any]:
|
|
8
|
+
"""Parse a list of ``key=value`` tokens into a flat override dict.
|
|
9
|
+
|
|
10
|
+
Values are coerced with best-effort JSON parsing (so ``5`` → int, ``5.0``
|
|
11
|
+
→ float, ``true`` / ``false`` → bool, ``null`` → ``None``, ``[1,2]`` →
|
|
12
|
+
list) and fall back to the raw string. Pydantic re-validates at
|
|
13
|
+
assignment, so the coercion is a hint, not a contract.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
tokens: List of CLI tokens, each of the form ``key=value``.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Flat mapping of keys to coerced values.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: If a token has no ``=``, or has an empty key.
|
|
23
|
+
"""
|
|
24
|
+
out: dict[str, Any] = {}
|
|
25
|
+
for tok in tokens:
|
|
26
|
+
if "=" not in tok:
|
|
27
|
+
raise ValueError(f"Bad override token {tok!r}: expected key=value.")
|
|
28
|
+
key, _, raw = tok.partition("=")
|
|
29
|
+
if not key:
|
|
30
|
+
raise ValueError(f"Bad override token {tok!r}: empty key.")
|
|
31
|
+
out[key] = _coerce(raw)
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _coerce(raw: str) -> Any:
|
|
36
|
+
if raw == "None":
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(raw)
|
|
40
|
+
except (ValueError, json.JSONDecodeError):
|
|
41
|
+
return raw
|
onefig/_cli_entry.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from onefig._completion import python_completion_script, shell_script
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="onefig",
|
|
12
|
+
description=(
|
|
13
|
+
"onefig command-line utilities. The library's primary API is the "
|
|
14
|
+
"Python ConfigModel; this CLI exposes a small number of install "
|
|
15
|
+
"helpers for shell tab completion."
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
19
|
+
|
|
20
|
+
p_install = sub.add_parser(
|
|
21
|
+
"install-completion",
|
|
22
|
+
help=(
|
|
23
|
+
"Print a shell-completion install snippet bound to a specific "
|
|
24
|
+
"script command name."
|
|
25
|
+
),
|
|
26
|
+
description=(
|
|
27
|
+
"Print a shell-completion install snippet for a script. Source "
|
|
28
|
+
"the output (or append it to your shell rc file) to enable TAB "
|
|
29
|
+
"completion for the named command."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
p_install.add_argument(
|
|
33
|
+
"shell",
|
|
34
|
+
choices=("bash", "zsh", "fish"),
|
|
35
|
+
help="Target shell.",
|
|
36
|
+
)
|
|
37
|
+
p_install.add_argument(
|
|
38
|
+
"--prog",
|
|
39
|
+
required=True,
|
|
40
|
+
metavar="NAME",
|
|
41
|
+
help="Command name the user types to invoke the script.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
p_py = sub.add_parser(
|
|
45
|
+
"install-python-completion",
|
|
46
|
+
help=(
|
|
47
|
+
"Print a generic shell snippet that enables tab completion for "
|
|
48
|
+
"every onefig script invoked via `python <script>.py`."
|
|
49
|
+
),
|
|
50
|
+
description=(
|
|
51
|
+
"Print a generic shell snippet that hooks tab completion onto "
|
|
52
|
+
"`python` and `python3`. After installing it once, every "
|
|
53
|
+
"onefig-based script invoked via `python script.py` gets "
|
|
54
|
+
"completion automatically."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
p_py.add_argument(
|
|
58
|
+
"shell",
|
|
59
|
+
choices=("bash", "zsh", "fish"),
|
|
60
|
+
help="Target shell.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv: list[str] | None = None) -> int:
|
|
67
|
+
"""Entry point for the ``onefig`` console script.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
argv: CLI arguments. Defaults to ``sys.argv[1:]``.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Process exit code (``0`` on success).
|
|
74
|
+
"""
|
|
75
|
+
args = build_parser().parse_args(argv)
|
|
76
|
+
if args.command == "install-completion":
|
|
77
|
+
print(shell_script(args.shell, prog=args.prog))
|
|
78
|
+
elif args.command == "install-python-completion":
|
|
79
|
+
print(python_completion_script(args.shell))
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
onefig/_completion.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from onefig._overrides import _resolve_keys
|
|
8
|
+
|
|
9
|
+
_SPECIAL_FLAGS = ("--show", "--help", "-h")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def completion_candidates(model: BaseModel) -> list[str]:
|
|
13
|
+
"""Return every override-key candidate the shell should offer.
|
|
14
|
+
|
|
15
|
+
Each scalar field contributes its full dotted path suffixed with ``=``
|
|
16
|
+
(e.g. ``"optimizer.lr="``). When the leaf name resolves uniquely it is
|
|
17
|
+
also offered as a shorthand (``"lr="``); ambiguous leaves are omitted
|
|
18
|
+
so users aren't shown a shortcut the override engine would refuse.
|
|
19
|
+
|
|
20
|
+
Special flags (``--show``, ``--help``, ``-h``) round out the list.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
model: A Pydantic model instance to introspect.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A flat list of completion strings, in stable insertion order.
|
|
27
|
+
"""
|
|
28
|
+
candidates = _resolve_keys(model)
|
|
29
|
+
seen: set[str] = set()
|
|
30
|
+
out: list[str] = []
|
|
31
|
+
for key, paths in candidates.items():
|
|
32
|
+
if len(paths) != 1:
|
|
33
|
+
# Ambiguous leaf — skip; the full-path form will appear separately.
|
|
34
|
+
continue
|
|
35
|
+
token = f"{key}="
|
|
36
|
+
if token not in seen:
|
|
37
|
+
seen.add(token)
|
|
38
|
+
out.append(token)
|
|
39
|
+
out.extend(_SPECIAL_FLAGS)
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def shell_script(shell: str, *, prog: str) -> str:
|
|
44
|
+
"""Render a shell-completion install snippet for ``prog``.
|
|
45
|
+
|
|
46
|
+
The generated script binds tab-completion of ``prog`` to a callback
|
|
47
|
+
that invokes ``prog --onefig-completions <prefix>`` and uses the
|
|
48
|
+
output as the candidate list. Source the snippet in your shell rc to
|
|
49
|
+
install (or eval it inline for one-shot use).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
shell: One of ``"bash"``, ``"zsh"``, ``"fish"``.
|
|
53
|
+
prog: The command name the user types to invoke the script (used
|
|
54
|
+
both as the completion target and as the callback command).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A shell snippet ready to write to a file or eval.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If ``shell`` isn't one of the supported shells.
|
|
61
|
+
"""
|
|
62
|
+
if shell == "bash":
|
|
63
|
+
return _bash_script(prog)
|
|
64
|
+
if shell == "zsh":
|
|
65
|
+
return _zsh_script(prog)
|
|
66
|
+
if shell == "fish":
|
|
67
|
+
return _fish_script(prog)
|
|
68
|
+
raise ValueError(f"Unsupported shell {shell!r}; expected one of: bash, zsh, fish.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _safe_func_suffix(prog: str) -> str:
|
|
72
|
+
"""Turn ``prog`` into an identifier-safe suffix for the completion function."""
|
|
73
|
+
out = []
|
|
74
|
+
for ch in prog:
|
|
75
|
+
if ch.isalnum() or ch == "_":
|
|
76
|
+
out.append(ch)
|
|
77
|
+
else:
|
|
78
|
+
out.append("_")
|
|
79
|
+
return "".join(out) or "onefig"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_BANNER_OPEN = "# ========== onefig: tab completion =========="
|
|
83
|
+
_BANNER_CLOSE = "# ============================================"
|
|
84
|
+
_PY_BANNER_OPEN = "# ====== onefig: python tab completion ======="
|
|
85
|
+
_PY_BANNER_CLOSE = "# ============================================"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bash_script(prog: str) -> str:
|
|
89
|
+
func = f"_onefig_complete_{_safe_func_suffix(prog)}"
|
|
90
|
+
qprog = shlex.quote(prog)
|
|
91
|
+
return f"""\
|
|
92
|
+
{_BANNER_OPEN}
|
|
93
|
+
{func}() {{
|
|
94
|
+
local cur
|
|
95
|
+
cur="${{COMP_WORDS[COMP_CWORD]}}"
|
|
96
|
+
local IFS=$'\\n'
|
|
97
|
+
local candidates
|
|
98
|
+
candidates=$({qprog} --onefig-completions "$cur" 2>/dev/null)
|
|
99
|
+
COMPREPLY=( $(compgen -W "$candidates" -- "$cur") )
|
|
100
|
+
}}
|
|
101
|
+
complete -o nospace -F {func} {qprog}
|
|
102
|
+
{_BANNER_CLOSE}
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _zsh_script(prog: str) -> str:
|
|
107
|
+
func = f"_onefig_complete_{_safe_func_suffix(prog)}"
|
|
108
|
+
qprog = shlex.quote(prog)
|
|
109
|
+
return f"""\
|
|
110
|
+
{_BANNER_OPEN}
|
|
111
|
+
{func}() {{
|
|
112
|
+
local -a candidates
|
|
113
|
+
candidates=("${{(@f)$({qprog} --onefig-completions \"$PREFIX\" 2>/dev/null)}}")
|
|
114
|
+
[[ ${{#candidates[@]}} -gt 0 ]] && compadd -S '' -- $candidates
|
|
115
|
+
}}
|
|
116
|
+
if (( ! ${{+functions[compdef]}} )); then
|
|
117
|
+
autoload -Uz compinit 2>/dev/null && compinit -u 2>/dev/null
|
|
118
|
+
fi
|
|
119
|
+
(( ${{+functions[compdef]}} )) && compdef {func} {prog}
|
|
120
|
+
{_BANNER_CLOSE}
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fish_script(prog: str) -> str:
|
|
125
|
+
func = f"__onefig_complete_{_safe_func_suffix(prog)}"
|
|
126
|
+
qprog = shlex.quote(prog)
|
|
127
|
+
return f"""\
|
|
128
|
+
{_BANNER_OPEN}
|
|
129
|
+
function {func}
|
|
130
|
+
set -l cur (commandline -t)
|
|
131
|
+
{qprog} --onefig-completions "$cur" 2>/dev/null
|
|
132
|
+
end
|
|
133
|
+
complete -c {prog} -f -a '({func})'
|
|
134
|
+
{_BANNER_CLOSE}
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def python_completion_script(shell: str) -> str:
|
|
139
|
+
"""Render an install snippet for ``python <script>.py`` tab completion.
|
|
140
|
+
|
|
141
|
+
Bound to ``python`` / ``python3`` (and friends), the generated function
|
|
142
|
+
walks the current command line to find the script arg. Before
|
|
143
|
+
invoking the script it greps for the literal word ``onefig`` in the
|
|
144
|
+
file; if not found, the wrapper returns immediately without running
|
|
145
|
+
the script. This keeps TAB safe for arbitrary Python scripts that
|
|
146
|
+
have side effects at import time. Onefig scripts (anything that
|
|
147
|
+
``import``s or references ``onefig``) match the grep and are invoked
|
|
148
|
+
with ``--onefig-completions <prefix>``; the printed candidates are
|
|
149
|
+
used as the completion list.
|
|
150
|
+
|
|
151
|
+
Falls back to file completion while the user is still typing the
|
|
152
|
+
script path.
|
|
153
|
+
|
|
154
|
+
One-time install. Sourcing the snippet enables completion for every
|
|
155
|
+
onefig-based script invoked via ``python``, regardless of whether the
|
|
156
|
+
script is on ``$PATH`` or directly executable.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
shell: One of ``"bash"``, ``"zsh"``, ``"fish"``.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
A shell snippet ready to write to a file or eval.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ValueError: If ``shell`` isn't one of the supported shells.
|
|
166
|
+
"""
|
|
167
|
+
if shell == "bash":
|
|
168
|
+
return _python_bash_script()
|
|
169
|
+
if shell == "zsh":
|
|
170
|
+
return _python_zsh_script()
|
|
171
|
+
if shell == "fish":
|
|
172
|
+
return _python_fish_script()
|
|
173
|
+
raise ValueError(f"Unsupported shell {shell!r}; expected one of: bash, zsh, fish.")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_PYTHON_NAMES = (
|
|
177
|
+
"python",
|
|
178
|
+
"python3",
|
|
179
|
+
"python3.9",
|
|
180
|
+
"python3.10",
|
|
181
|
+
"python3.11",
|
|
182
|
+
"python3.12",
|
|
183
|
+
"python3.13",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _python_bash_script() -> str:
|
|
188
|
+
targets = " ".join(_PYTHON_NAMES)
|
|
189
|
+
return f"""\
|
|
190
|
+
{_PY_BANNER_OPEN}
|
|
191
|
+
_onefig_python_complete() {{
|
|
192
|
+
local cur script i
|
|
193
|
+
cur="${{COMP_WORDS[COMP_CWORD]}}"
|
|
194
|
+
script=""
|
|
195
|
+
for ((i=1; i<COMP_CWORD; i++)); do
|
|
196
|
+
case "${{COMP_WORDS[i]}}" in
|
|
197
|
+
-*) ;;
|
|
198
|
+
*.py)
|
|
199
|
+
if [[ -f "${{COMP_WORDS[i]}}" ]]; then
|
|
200
|
+
script="${{COMP_WORDS[i]}}"
|
|
201
|
+
break
|
|
202
|
+
fi
|
|
203
|
+
;;
|
|
204
|
+
esac
|
|
205
|
+
done
|
|
206
|
+
if [[ -z "$script" ]]; then
|
|
207
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
208
|
+
return
|
|
209
|
+
fi
|
|
210
|
+
# Skip non-onefig scripts to avoid running their side effects on TAB.
|
|
211
|
+
grep -qw onefig "$script" 2>/dev/null || return
|
|
212
|
+
local IFS=$'\\n'
|
|
213
|
+
local candidates
|
|
214
|
+
candidates=$("${{COMP_WORDS[0]}}" "$script" --onefig-completions "$cur" 2>/dev/null)
|
|
215
|
+
if [[ -z "$candidates" ]]; then
|
|
216
|
+
return
|
|
217
|
+
fi
|
|
218
|
+
COMPREPLY=( $(compgen -W "$candidates" -- "$cur") )
|
|
219
|
+
}}
|
|
220
|
+
complete -o nospace -F _onefig_python_complete {targets}
|
|
221
|
+
{_PY_BANNER_CLOSE}
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _python_zsh_script() -> str:
|
|
226
|
+
targets = " ".join(_PYTHON_NAMES)
|
|
227
|
+
cand_expr = (
|
|
228
|
+
'("${(@f)$($python_cmd "$script" '
|
|
229
|
+
'--onefig-completions "$PREFIX" 2>/dev/null)}")'
|
|
230
|
+
)
|
|
231
|
+
return f"""\
|
|
232
|
+
{_PY_BANNER_OPEN}
|
|
233
|
+
_onefig_python_complete() {{
|
|
234
|
+
local script python_cmd i
|
|
235
|
+
python_cmd="${{words[1]}}"
|
|
236
|
+
script=""
|
|
237
|
+
for ((i=2; i<CURRENT; i++)); do
|
|
238
|
+
case "${{words[i]}}" in
|
|
239
|
+
-*) ;;
|
|
240
|
+
*.py)
|
|
241
|
+
if [[ -f "${{words[i]}}" ]]; then
|
|
242
|
+
script="${{words[i]}}"
|
|
243
|
+
break
|
|
244
|
+
fi
|
|
245
|
+
;;
|
|
246
|
+
esac
|
|
247
|
+
done
|
|
248
|
+
if [[ -z "$script" ]]; then
|
|
249
|
+
_files
|
|
250
|
+
return
|
|
251
|
+
fi
|
|
252
|
+
# Skip non-onefig scripts to avoid running their side effects on TAB.
|
|
253
|
+
grep -qw onefig "$script" 2>/dev/null || return
|
|
254
|
+
local -a candidates
|
|
255
|
+
candidates={cand_expr}
|
|
256
|
+
[[ ${{#candidates[@]}} -gt 0 ]] && compadd -S '' -- $candidates
|
|
257
|
+
}}
|
|
258
|
+
if (( ! ${{+functions[compdef]}} )); then
|
|
259
|
+
autoload -Uz compinit 2>/dev/null && compinit -u 2>/dev/null
|
|
260
|
+
fi
|
|
261
|
+
(( ${{+functions[compdef]}} )) && compdef _onefig_python_complete {targets}
|
|
262
|
+
{_PY_BANNER_CLOSE}
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _python_fish_script() -> str:
|
|
267
|
+
cmds = " ".join(f"-c {n}" for n in _PYTHON_NAMES)
|
|
268
|
+
return f"""\
|
|
269
|
+
{_PY_BANNER_OPEN}
|
|
270
|
+
function __onefig_python_complete
|
|
271
|
+
set -l tokens (commandline -opc)
|
|
272
|
+
set -l cur (commandline -t)
|
|
273
|
+
set -l script ""
|
|
274
|
+
for tok in $tokens[2..]
|
|
275
|
+
if string match -q -- '-*' $tok
|
|
276
|
+
continue
|
|
277
|
+
end
|
|
278
|
+
if string match -q -- '*.py' $tok; and test -f "$tok"
|
|
279
|
+
set script $tok
|
|
280
|
+
break
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
if test -z "$script"
|
|
284
|
+
__fish_complete_path "$cur"
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
# Skip non-onefig scripts to avoid running their side effects on TAB.
|
|
288
|
+
if not grep -qw onefig "$script" 2>/dev/null
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
$tokens[1] "$script" --onefig-completions "$cur" 2>/dev/null
|
|
292
|
+
end
|
|
293
|
+
complete {cmds} -f -a '(__onefig_python_complete)'
|
|
294
|
+
{_PY_BANNER_CLOSE}
|
|
295
|
+
"""
|
onefig/_diff.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
_RED = "\033[31m"
|
|
7
|
+
_GREEN = "\033[32m"
|
|
8
|
+
_DIM = "\033[2m"
|
|
9
|
+
_RESET = "\033[0m"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _MissingType:
|
|
13
|
+
"""Singleton sentinel for keys that are absent from one side of a diff."""
|
|
14
|
+
|
|
15
|
+
_instance: _MissingType | None = None
|
|
16
|
+
|
|
17
|
+
def __new__(cls) -> _MissingType:
|
|
18
|
+
if cls._instance is None:
|
|
19
|
+
cls._instance = super().__new__(cls)
|
|
20
|
+
return cls._instance
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return "<MISSING>"
|
|
24
|
+
|
|
25
|
+
def __bool__(self) -> bool:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MISSING: Any = _MissingType()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compute_diff(
|
|
33
|
+
a: dict[str, Any], b: dict[str, Any]
|
|
34
|
+
) -> dict[str, tuple[Any, Any]]:
|
|
35
|
+
"""Diff two flat dotted-key dicts.
|
|
36
|
+
|
|
37
|
+
Returns ``{path: (old, new)}`` for every key whose value differs.
|
|
38
|
+
Keys present in only one side use the :data:`MISSING` sentinel for
|
|
39
|
+
the absent side.
|
|
40
|
+
|
|
41
|
+
The returned dict is ordered: keys present in ``a`` come first (in
|
|
42
|
+
``a``'s order), then keys only present in ``b`` (in ``b``'s order).
|
|
43
|
+
Makes diff output stable and easy to scan.
|
|
44
|
+
"""
|
|
45
|
+
out: dict[str, tuple[Any, Any]] = {}
|
|
46
|
+
for k, va in a.items():
|
|
47
|
+
vb = b.get(k, MISSING)
|
|
48
|
+
if va != vb:
|
|
49
|
+
out[k] = (va, vb)
|
|
50
|
+
for k, vb in b.items():
|
|
51
|
+
if k in a:
|
|
52
|
+
continue
|
|
53
|
+
out[k] = (MISSING, vb)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_diff(
|
|
58
|
+
diff: dict[str, tuple[Any, Any]],
|
|
59
|
+
*,
|
|
60
|
+
color: bool | None = None,
|
|
61
|
+
empty_message: str = "(no changes)",
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Render a diff dict as an aligned ``old → new`` table.
|
|
64
|
+
|
|
65
|
+
Every row uses the side-by-side ``key old → new`` layout, with
|
|
66
|
+
ANSI red on the old side, green on the new side, and dimmed
|
|
67
|
+
``<MISSING>`` for any side without a value (cross-schema diffs
|
|
68
|
+
against partial dicts).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
diff: Mapping from dotted path to ``(old, new)`` tuple (the
|
|
72
|
+
output of :func:`compute_diff` / :meth:`ConfigModel.diff`).
|
|
73
|
+
color: ``True`` / ``False`` to force ANSI on or off. ``None``
|
|
74
|
+
(default) auto-detects via ``sys.stdout.isatty()``.
|
|
75
|
+
empty_message: Text to return when ``diff`` is empty.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A multi-line string ready to print.
|
|
79
|
+
"""
|
|
80
|
+
if not diff:
|
|
81
|
+
return empty_message
|
|
82
|
+
|
|
83
|
+
use_color = sys.stdout.isatty() if color is None else color
|
|
84
|
+
|
|
85
|
+
raw_olds = [_format_value(old) for old, _ in diff.values()]
|
|
86
|
+
raw_news = [_format_value(new) for _, new in diff.values()]
|
|
87
|
+
|
|
88
|
+
key_width = max(len(k) for k in diff)
|
|
89
|
+
old_width = max(len(s) for s in raw_olds)
|
|
90
|
+
|
|
91
|
+
lines: list[str] = []
|
|
92
|
+
for (key, (old, new)), old_str, new_str in zip(
|
|
93
|
+
diff.items(), raw_olds, raw_news
|
|
94
|
+
):
|
|
95
|
+
old_padded = old_str.ljust(old_width)
|
|
96
|
+
old_styled = _style_value(old_padded, old, _RED, enabled=use_color)
|
|
97
|
+
new_styled = _style_value(new_str, new, _GREEN, enabled=use_color)
|
|
98
|
+
lines.append(f" {key.ljust(key_width)} {old_styled} → {new_styled}")
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def format_against_defaults(
|
|
103
|
+
current: dict[str, Any],
|
|
104
|
+
defaults: dict[str, Any],
|
|
105
|
+
*,
|
|
106
|
+
color: bool | None = None,
|
|
107
|
+
empty_message: str = "(empty config)",
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Render a config's full state with override highlights.
|
|
110
|
+
|
|
111
|
+
Walks every key in ``current``. For fields that match ``defaults``,
|
|
112
|
+
the value renders alone in green (it's the active value, untouched).
|
|
113
|
+
For fields that diverge, the row renders as ``default → current``
|
|
114
|
+
with the default in red and the current in green.
|
|
115
|
+
|
|
116
|
+
Useful as a "what does this run actually look like, and where did I
|
|
117
|
+
deviate from defaults?" snapshot.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
current: Flat dotted-key dict of the config's current values.
|
|
121
|
+
defaults: Flat dotted-key dict of the schema's default values.
|
|
122
|
+
color: ``True`` / ``False`` to force ANSI on or off. ``None``
|
|
123
|
+
(default) auto-detects via ``sys.stdout.isatty()``.
|
|
124
|
+
empty_message: Text to return when ``current`` is empty.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A multi-line string ready to print.
|
|
128
|
+
"""
|
|
129
|
+
if not current:
|
|
130
|
+
return empty_message
|
|
131
|
+
|
|
132
|
+
use_color = sys.stdout.isatty() if color is None else color
|
|
133
|
+
|
|
134
|
+
keys = list(current.keys())
|
|
135
|
+
key_width = max(len(k) for k in keys)
|
|
136
|
+
|
|
137
|
+
overridden_olds = [
|
|
138
|
+
repr(defaults[k])
|
|
139
|
+
for k in keys
|
|
140
|
+
if k in defaults and defaults[k] != current[k]
|
|
141
|
+
]
|
|
142
|
+
old_width = max((len(s) for s in overridden_olds), default=0)
|
|
143
|
+
|
|
144
|
+
lines: list[str] = []
|
|
145
|
+
for key in keys:
|
|
146
|
+
key_col = key.ljust(key_width)
|
|
147
|
+
cur_val = current[key]
|
|
148
|
+
cur_repr = repr(cur_val)
|
|
149
|
+
if key in defaults and defaults[key] == cur_val:
|
|
150
|
+
# Unchanged — single green value, no arrow.
|
|
151
|
+
value_styled = _style(cur_repr, _GREEN, enabled=use_color)
|
|
152
|
+
lines.append(f" {key_col} {value_styled}")
|
|
153
|
+
else:
|
|
154
|
+
# Overridden — default in red, current in green.
|
|
155
|
+
default_repr = (
|
|
156
|
+
repr(defaults[key]) if key in defaults else "<MISSING>"
|
|
157
|
+
)
|
|
158
|
+
old_padded = default_repr.ljust(old_width)
|
|
159
|
+
old_styled = _style(old_padded, _RED, enabled=use_color)
|
|
160
|
+
new_styled = _style(cur_repr, _GREEN, enabled=use_color)
|
|
161
|
+
lines.append(f" {key_col} {old_styled} → {new_styled}")
|
|
162
|
+
return "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _format_value(value: Any) -> str:
|
|
166
|
+
if value is MISSING:
|
|
167
|
+
return "<MISSING>"
|
|
168
|
+
return repr(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _style_value(
|
|
172
|
+
text: str, value: Any, color_code: str, *, enabled: bool
|
|
173
|
+
) -> str:
|
|
174
|
+
"""Style a value's rendered text. MISSING dims; everything else uses
|
|
175
|
+
its side's color."""
|
|
176
|
+
if not enabled:
|
|
177
|
+
return text
|
|
178
|
+
code = _DIM if value is MISSING else color_code
|
|
179
|
+
return f"{code}{text}{_RESET}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _style(text: str, color_code: str, *, enabled: bool) -> str:
|
|
183
|
+
if not enabled:
|
|
184
|
+
return text
|
|
185
|
+
return f"{color_code}{text}{_RESET}"
|
onefig/_env.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from onefig._cli import _coerce
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_env(
|
|
10
|
+
environ: Mapping[str, str],
|
|
11
|
+
*,
|
|
12
|
+
prefix: str,
|
|
13
|
+
delimiter: str = "__",
|
|
14
|
+
case_sensitive: bool = False,
|
|
15
|
+
) -> dict[str, Any]:
|
|
16
|
+
"""Parse environment variables into a flat override dict.
|
|
17
|
+
|
|
18
|
+
Keys that don't start with ``prefix`` are ignored. For matching keys,
|
|
19
|
+
the prefix is stripped, ``delimiter`` is replaced with ``.`` (so a
|
|
20
|
+
POSIX-legal name can address nested fields), and the result is
|
|
21
|
+
lowercased unless ``case_sensitive`` is set. Values are coerced with
|
|
22
|
+
best-effort JSON parsing (same rules as the CLI parser).
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
environ: Mapping of env var names to string values (typically
|
|
26
|
+
``os.environ``).
|
|
27
|
+
prefix: Required prefix to scope which env vars are consumed.
|
|
28
|
+
Pass ``""`` to read every variable (rarely what you want).
|
|
29
|
+
delimiter: Substring that separates nested-field segments inside
|
|
30
|
+
an env var name. Defaults to ``"__"``, matching the
|
|
31
|
+
pydantic-settings convention.
|
|
32
|
+
case_sensitive: If ``False`` (default), keys are lowercased after
|
|
33
|
+
stripping the prefix. Set ``True`` for schemas with mixed-case
|
|
34
|
+
field names.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Flat mapping of override keys (e.g. ``"model.lr"``) to coerced
|
|
38
|
+
values, ready to hand to ``apply_overrides``.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If a matching env var, after stripping the prefix and
|
|
42
|
+
splitting on the delimiter, contains an empty segment.
|
|
43
|
+
"""
|
|
44
|
+
out: dict[str, Any] = {}
|
|
45
|
+
for raw_name, raw_value in environ.items():
|
|
46
|
+
if not raw_name.startswith(prefix):
|
|
47
|
+
continue
|
|
48
|
+
tail = raw_name[len(prefix) :]
|
|
49
|
+
if not tail:
|
|
50
|
+
# Var name equals the prefix exactly; nothing to address.
|
|
51
|
+
continue
|
|
52
|
+
segments = tail.split(delimiter)
|
|
53
|
+
if any(seg == "" for seg in segments):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Env var {raw_name!r} produces an empty key segment "
|
|
56
|
+
f"after stripping prefix {prefix!r} and splitting on "
|
|
57
|
+
f"{delimiter!r}."
|
|
58
|
+
)
|
|
59
|
+
key = ".".join(segments)
|
|
60
|
+
if not case_sensitive:
|
|
61
|
+
key = key.lower()
|
|
62
|
+
out[key] = _coerce(raw_value)
|
|
63
|
+
return out
|