Repository navigation
Expand file tree
/
Copy pathcli_json.py
More file actions
143 lines (124 loc) · 3.75 KB
/
Copy pathcli_json.py
File metadata and controls
143 lines (124 loc) · 3.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def _option_value_count(action: argparse.Action) -> int:
"""How many following argv tokens an option consumes as its value(s)."""
nargs = action.nargs
if nargs is None or nargs == "?":
return 1
if isinstance(nargs, int):
return nargs # 0 for flags such as store_true
if nargs in ("*", "+", argparse.REMAINDER):
return sys.maxsize
return 0
def split_cfg_argv(
argv: list[str] | None,
parser: argparse.ArgumentParser | None = None,
) -> tuple[list[str], list[str] | None]:
"""Separate config ``*.json`` paths from the remaining CLI tokens.
With ``parser``, a ``.json`` token that is the value of a preceding option
(for example ``--input vol.lasagna.json``) stays with that option instead of
being taken as a config file.
"""
if argv is None:
return [], None
paths: list[str] = []
rest: list[str] = []
pending_values = 0
for a in argv:
if pending_values > 0 and not a.startswith("-"):
rest.append(a)
pending_values -= 1
continue
pending_values = 0
if a.startswith("-"):
rest.append(a)
if parser is not None and "=" not in a:
action = parser._option_string_actions.get(a)
if action is not None:
pending_values = _option_value_count(action)
continue
if str(a).endswith(".json"):
paths.append(a)
continue
rest.append(a)
return paths, rest
def _coerce_action_default(*, a: argparse.Action, v: object) -> object:
if v is None:
return None
if a.nargs in {"+", "*"}:
if not isinstance(v, list):
return v
if a.type is None:
return [str(x) for x in v]
return [a.type(x) for x in v]
if isinstance(a.nargs, int):
if not isinstance(v, list):
return v
if a.type is None:
return [str(x) for x in v]
return [a.type(x) for x in v]
if a.type is None:
return v
return a.type(v)
def merge_cfgs(paths: list[str]) -> dict:
def _merge(a: object, b: object) -> object:
if isinstance(a, dict) and isinstance(b, dict):
out = dict(a)
for k, v in b.items():
if k in out:
out[k] = _merge(out[k], v)
else:
out[k] = v
return out
return b
merged: dict = {}
for pj in paths:
try:
cfg = json.loads(Path(pj).read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(
f"invalid JSON in config {pj}: line {exc.lineno}, column {exc.colno}: {exc.msg}"
) from exc
if not isinstance(cfg, dict):
raise ValueError(f"json must contain an object at top-level: {pj}")
merged = _merge(merged, cfg)
return merged
def add_args(p: argparse.ArgumentParser) -> None:
p.add_argument(
"cfg",
nargs="*",
help="Optional json config file(s) merged in order; any non-flag *.json is treated as config.",
)
def apply_defaults_from_cfg_args(p: argparse.ArgumentParser, cfg: dict) -> None:
args_cfg = cfg.get("args", {})
if args_cfg is None:
args_cfg = {}
if not isinstance(args_cfg, dict):
raise ValueError("json key 'args' must be an object")
actions = {a.dest: a for a in p._actions if getattr(a, "dest", None)}
opts: dict[str, argparse.Action] = {}
for a in p._actions:
for s in getattr(a, "option_strings", []) or []:
o = str(s).lstrip("-")
if o:
opts[o] = a
defaults: dict[str, object] = {}
for k, v in args_cfg.items():
key = str(k).lstrip("-")
a = opts.get(key)
if a is None:
a = actions.get(key.replace("-", "_"))
if a is None:
continue
dest = str(a.dest)
defaults[dest] = _coerce_action_default(a=a, v=v)
p.set_defaults(**defaults)
def parse_args(p: argparse.ArgumentParser, argv: list[str] | None) -> argparse.Namespace:
cfg_paths, argv_rest = split_cfg_argv(argv, p)
cfg_paths = [str(x) for x in cfg_paths]
cfg = merge_cfgs(cfg_paths) if cfg_paths else {}
apply_defaults_from_cfg_args(p, cfg)
return p.parse_args(argv_rest)