cli-pydantic 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- cli_pydantic-0.2.0/PKG-INFO +69 -0
- cli_pydantic-0.2.0/README.md +58 -0
- {cli_pydantic-0.1.0 → cli_pydantic-0.2.0}/pyproject.toml +6 -1
- cli_pydantic-0.2.0/src/cli_pydantic/__init__.py +3 -0
- cli_pydantic-0.2.0/src/cli_pydantic/lib.py +183 -0
- cli_pydantic-0.1.0/PKG-INFO +0 -11
- cli_pydantic-0.1.0/README.md +0 -0
- cli_pydantic-0.1.0/src/cli_pydantic/__init__.py +0 -1
- cli_pydantic-0.1.0/src/cli_pydantic/lib.py +0 -101
- {cli_pydantic-0.1.0 → cli_pydantic-0.2.0}/src/cli_pydantic/py.typed +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cli-pydantic
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Tom Pollak
|
|
6
|
+
Author-email: Tom Pollak <tomp@graphcore.ai>
|
|
7
|
+
Requires-Dist: pydantic>=2.12.5
|
|
8
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# cli-pydantic
|
|
13
|
+
|
|
14
|
+
Turn a Pydantic model into a CLI. I dislike every other CLI library so here's yet another one.
|
|
15
|
+
|
|
16
|
+
- CLI defined by Pydantic
|
|
17
|
+
- Use multiple YAML / JSON configs with `--flag` CLI overrides.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
pip install cli-pydantic
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
# train.py
|
|
29
|
+
from pydantic import BaseModel
|
|
30
|
+
from cli_pydantic import cli
|
|
31
|
+
|
|
32
|
+
class Data(BaseModel):
|
|
33
|
+
path: str = "./data"
|
|
34
|
+
splits: list[str] = ["train", "val"]
|
|
35
|
+
|
|
36
|
+
class Model(BaseModel):
|
|
37
|
+
arch: str = "resnet50"
|
|
38
|
+
lr: float = 1e-3
|
|
39
|
+
layers: list[int] = [64, 128, 256]
|
|
40
|
+
|
|
41
|
+
class Config(BaseModel):
|
|
42
|
+
data: Data = Data()
|
|
43
|
+
model: Model = Model()
|
|
44
|
+
epochs: int = 10
|
|
45
|
+
verbose: bool = False
|
|
46
|
+
|
|
47
|
+
cfg = cli(Config, desc="Training pipeline")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# CLI, use default from Pydantic
|
|
52
|
+
python train.py --model.arch vit_base --model.lr 3e-4 --epochs 50
|
|
53
|
+
|
|
54
|
+
# From a config file
|
|
55
|
+
python train.py base.yaml
|
|
56
|
+
|
|
57
|
+
# Layer multiple configs, then override with flags
|
|
58
|
+
python train.py base.yaml fast.yaml --model.lr 0.05 --epochs 3
|
|
59
|
+
|
|
60
|
+
# Lists and booleans
|
|
61
|
+
python train.py --model.layers 16,32 --verbose
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Semantics:
|
|
65
|
+
|
|
66
|
+
- Use `--foo bar` or `--foo=bar`
|
|
67
|
+
- For lists: `--nums=1,2,3` or `--nums 1 --nums=2 --nums 3`
|
|
68
|
+
- For bools: `--enable` / `--no-enable`
|
|
69
|
+
- Lists will _replace_ previous configs on override -- not append!
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# cli-pydantic
|
|
2
|
+
|
|
3
|
+
Turn a Pydantic model into a CLI. I dislike every other CLI library so here's yet another one.
|
|
4
|
+
|
|
5
|
+
- CLI defined by Pydantic
|
|
6
|
+
- Use multiple YAML / JSON configs with `--flag` CLI overrides.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
pip install cli-pydantic
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
# train.py
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
from cli_pydantic import cli
|
|
20
|
+
|
|
21
|
+
class Data(BaseModel):
|
|
22
|
+
path: str = "./data"
|
|
23
|
+
splits: list[str] = ["train", "val"]
|
|
24
|
+
|
|
25
|
+
class Model(BaseModel):
|
|
26
|
+
arch: str = "resnet50"
|
|
27
|
+
lr: float = 1e-3
|
|
28
|
+
layers: list[int] = [64, 128, 256]
|
|
29
|
+
|
|
30
|
+
class Config(BaseModel):
|
|
31
|
+
data: Data = Data()
|
|
32
|
+
model: Model = Model()
|
|
33
|
+
epochs: int = 10
|
|
34
|
+
verbose: bool = False
|
|
35
|
+
|
|
36
|
+
cfg = cli(Config, desc="Training pipeline")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# CLI, use default from Pydantic
|
|
41
|
+
python train.py --model.arch vit_base --model.lr 3e-4 --epochs 50
|
|
42
|
+
|
|
43
|
+
# From a config file
|
|
44
|
+
python train.py base.yaml
|
|
45
|
+
|
|
46
|
+
# Layer multiple configs, then override with flags
|
|
47
|
+
python train.py base.yaml fast.yaml --model.lr 0.05 --epochs 3
|
|
48
|
+
|
|
49
|
+
# Lists and booleans
|
|
50
|
+
python train.py --model.layers 16,32 --verbose
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Semantics:
|
|
54
|
+
|
|
55
|
+
- Use `--foo bar` or `--foo=bar`
|
|
56
|
+
- For lists: `--nums=1,2,3` or `--nums 1 --nums=2 --nums 3`
|
|
57
|
+
- For bools: `--enable` / `--no-enable`
|
|
58
|
+
- Lists will _replace_ previous configs on override -- not append!
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cli-pydantic"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Add your description here"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -15,3 +15,8 @@ dependencies = [
|
|
|
15
15
|
[build-system]
|
|
16
16
|
requires = ["uv_build>=0.8.4,<0.9.0"]
|
|
17
17
|
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=9.0.2",
|
|
22
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from collections import deque
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import get_origin
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from pydantic_core import PydanticUndefined
|
|
10
|
+
|
|
11
|
+
__all__ = ["cli"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_field_type(model_cls: type[BaseModel], path: list[str]) -> type | None:
|
|
15
|
+
"""Walk a dotted path through nested BaseModels, return the leaf annotation."""
|
|
16
|
+
cls = model_cls
|
|
17
|
+
for p in path:
|
|
18
|
+
if p not in cls.model_fields:
|
|
19
|
+
return None
|
|
20
|
+
ann = cls.model_fields[p].annotation
|
|
21
|
+
if p == path[-1]:
|
|
22
|
+
return ann
|
|
23
|
+
if isinstance(ann, type) and issubclass(ann, BaseModel):
|
|
24
|
+
cls = ann
|
|
25
|
+
else:
|
|
26
|
+
return None
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_flags(tokens: list[str], model_cls: type[BaseModel]) -> dict:
|
|
31
|
+
out = {}
|
|
32
|
+
|
|
33
|
+
def route(key: str) -> list[str]:
|
|
34
|
+
parts = key.replace("-", "_").split(".")
|
|
35
|
+
if resolve_field_type(model_cls, parts) is None:
|
|
36
|
+
raise ValueError(f"Unknown option: --{key}")
|
|
37
|
+
return parts
|
|
38
|
+
|
|
39
|
+
def put(parts: list[str], val):
|
|
40
|
+
cur = out
|
|
41
|
+
for p in parts[:-1]:
|
|
42
|
+
cur = cur.setdefault(p, {})
|
|
43
|
+
|
|
44
|
+
k = parts[-1]
|
|
45
|
+
is_list = get_origin(resolve_field_type(model_cls, parts)) is list
|
|
46
|
+
|
|
47
|
+
if is_list:
|
|
48
|
+
vals = val.split(",") if isinstance(val, str) and "," in val else [val]
|
|
49
|
+
cur.setdefault(k, []).extend(vals)
|
|
50
|
+
elif cur.get(k, val) != val:
|
|
51
|
+
raise ValueError(f"Duplicate value for {'.'.join(parts)}")
|
|
52
|
+
else:
|
|
53
|
+
cur[k] = val
|
|
54
|
+
|
|
55
|
+
def has_value() -> bool:
|
|
56
|
+
return bool(q) and not q[0].startswith("--")
|
|
57
|
+
|
|
58
|
+
q = deque(tokens)
|
|
59
|
+
while q:
|
|
60
|
+
t = q.popleft()
|
|
61
|
+
if not t.startswith("--"):
|
|
62
|
+
raise ValueError(f"Expected --key, got: {t}")
|
|
63
|
+
|
|
64
|
+
s = t[2:]
|
|
65
|
+
|
|
66
|
+
if s.startswith("no-"): # --no-flag
|
|
67
|
+
if "=" in s or has_value():
|
|
68
|
+
raise ValueError(f"--no-* flags can't take a value: {t}")
|
|
69
|
+
key, val = s[3:], False
|
|
70
|
+
elif "=" in s: # --k=v
|
|
71
|
+
key, val = s.split("=", 1)
|
|
72
|
+
else: # --k v / --flag
|
|
73
|
+
key, val = s, (q.popleft() if has_value() else True)
|
|
74
|
+
|
|
75
|
+
put(route(key), val)
|
|
76
|
+
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def deep_merge(base: dict, overrides: dict) -> dict:
|
|
81
|
+
for k, v in overrides.items():
|
|
82
|
+
if isinstance(v, dict) and isinstance(base.get(k), dict):
|
|
83
|
+
deep_merge(base[k], v)
|
|
84
|
+
else:
|
|
85
|
+
base[k] = v
|
|
86
|
+
return base
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def model_help(model: type[BaseModel], prefix: str = "") -> list[str]:
|
|
90
|
+
def ty_name(ann) -> str:
|
|
91
|
+
name = getattr(ann, "__name__", None)
|
|
92
|
+
return name if name and "[" not in str(ann) else str(ann)
|
|
93
|
+
|
|
94
|
+
def entries(m, pfx):
|
|
95
|
+
out = []
|
|
96
|
+
for name, field in m.model_fields.items():
|
|
97
|
+
key = f"{pfx}{name}"
|
|
98
|
+
ann = field.annotation
|
|
99
|
+
if isinstance(ann, type) and issubclass(ann, BaseModel):
|
|
100
|
+
out.extend(entries(ann, f"{key}."))
|
|
101
|
+
else:
|
|
102
|
+
default = (
|
|
103
|
+
""
|
|
104
|
+
if field.default is PydanticUndefined
|
|
105
|
+
else f" (default: {field.default})"
|
|
106
|
+
)
|
|
107
|
+
desc = field.description or ""
|
|
108
|
+
out.append((f"--{key} {ty_name(ann)}", f"{desc}{default}"))
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
items = entries(model, prefix)
|
|
112
|
+
col = max((len(f) for f, _ in items), default=0) + 2
|
|
113
|
+
return [f" {f:<{col}}{h}" for f, h in items]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_config(path: Path) -> dict:
|
|
117
|
+
if not path.exists():
|
|
118
|
+
raise ValueError(f"Config file not found: {path}")
|
|
119
|
+
|
|
120
|
+
raw = path.read_text()
|
|
121
|
+
if not raw.strip():
|
|
122
|
+
data = {}
|
|
123
|
+
elif path.suffix == ".json":
|
|
124
|
+
data = json.loads(raw)
|
|
125
|
+
elif path.suffix in {".yaml", ".yml"}:
|
|
126
|
+
data = yaml.safe_load(raw)
|
|
127
|
+
else:
|
|
128
|
+
raise ValueError(f"Unsupported config file type: {path.suffix}")
|
|
129
|
+
|
|
130
|
+
if not isinstance(data, dict):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"Config file must contain a mapping, got {type(data).__name__}"
|
|
133
|
+
)
|
|
134
|
+
return data
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cli[T: BaseModel](model_cls: type[T], desc: str = "") -> T:
|
|
138
|
+
"""Build a CLI from a Pydantic model, merging config files and --overrides.
|
|
139
|
+
|
|
140
|
+
Positional arguments are paths to JSON/YAML config files (later files
|
|
141
|
+
override earlier ones). Any remaining ``--key value`` flags are parsed
|
|
142
|
+
as field overrides using dot-notation (e.g. ``--model.lr 0.01``).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
model_cls: The Pydantic model class that defines the config schema.
|
|
146
|
+
desc: Optional description shown in ``--help`` output.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A validated instance of *model_cls*.
|
|
150
|
+
"""
|
|
151
|
+
argv = sys.argv[1:]
|
|
152
|
+
|
|
153
|
+
def print_help():
|
|
154
|
+
prog = Path(sys.argv[0]).name
|
|
155
|
+
lines = [f"usage: {prog} [-h] [configs ...] [--overrides ...]"]
|
|
156
|
+
if desc:
|
|
157
|
+
lines.append(f"\n{desc}")
|
|
158
|
+
lines.append("\nconfig arguments:")
|
|
159
|
+
lines.extend(model_help(model_cls))
|
|
160
|
+
print("\n".join(lines))
|
|
161
|
+
raise SystemExit(0)
|
|
162
|
+
|
|
163
|
+
def split_argv() -> tuple[list[Path], list[str]]:
|
|
164
|
+
config_paths: list[Path] = []
|
|
165
|
+
for i, tok in enumerate(argv):
|
|
166
|
+
if tok.startswith("-"):
|
|
167
|
+
return config_paths, argv[i:]
|
|
168
|
+
config_paths.append(Path(tok))
|
|
169
|
+
return config_paths, []
|
|
170
|
+
|
|
171
|
+
if "-h" in argv or "--help" in argv:
|
|
172
|
+
print_help()
|
|
173
|
+
|
|
174
|
+
config_paths, flag_tokens = split_argv()
|
|
175
|
+
|
|
176
|
+
configs = [load_config(p) for p in config_paths]
|
|
177
|
+
overrides = parse_flags(flag_tokens, model_cls)
|
|
178
|
+
|
|
179
|
+
data = {}
|
|
180
|
+
for new in configs + [overrides]:
|
|
181
|
+
deep_merge(data, new)
|
|
182
|
+
|
|
183
|
+
return model_cls.model_validate(data)
|
cli_pydantic-0.1.0/PKG-INFO
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: cli-pydantic
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Add your description here
|
|
5
|
-
Author: Tom Pollak
|
|
6
|
-
Author-email: Tom Pollak <tomp@graphcore.ai>
|
|
7
|
-
Requires-Dist: pydantic>=2.12.5
|
|
8
|
-
Requires-Dist: pyyaml>=6.0.3
|
|
9
|
-
Requires-Python: >=3.12
|
|
10
|
-
Description-Content-Type: text/markdown
|
|
11
|
-
|
cli_pydantic-0.1.0/README.md
DELETED
|
File without changes
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
from .lib import parse_unknown_args, deep_merge, model_help
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
from collections import deque
|
|
2
|
-
from typing import get_origin
|
|
3
|
-
|
|
4
|
-
from pydantic import BaseModel
|
|
5
|
-
from pydantic_core import PydanticUndefined
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def resolve_field_type(model_cls: type[BaseModel], path: list[str]) -> type | None:
|
|
9
|
-
"""Walk a dotted path through nested BaseModels, return the leaf annotation."""
|
|
10
|
-
cls = model_cls
|
|
11
|
-
for p in path:
|
|
12
|
-
if p not in cls.model_fields:
|
|
13
|
-
return None
|
|
14
|
-
ann = cls.model_fields[p].annotation
|
|
15
|
-
if p == path[-1]:
|
|
16
|
-
return ann
|
|
17
|
-
if isinstance(ann, type) and issubclass(ann, BaseModel):
|
|
18
|
-
cls = ann
|
|
19
|
-
else:
|
|
20
|
-
return None
|
|
21
|
-
return None
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def parse_unknown_args(tokens: list[str], model_cls: type[BaseModel]) -> dict:
|
|
25
|
-
out = {}
|
|
26
|
-
q = deque(tokens)
|
|
27
|
-
|
|
28
|
-
def has_value() -> bool:
|
|
29
|
-
return bool(q) and not q[0].startswith("--")
|
|
30
|
-
|
|
31
|
-
def route(key: str) -> list[str]:
|
|
32
|
-
parts = key.replace("-", "_").split(".")
|
|
33
|
-
if resolve_field_type(model_cls, parts) is None:
|
|
34
|
-
raise ValueError(f"Unknown option: --{key}")
|
|
35
|
-
return parts
|
|
36
|
-
|
|
37
|
-
def put(parts: list[str], val):
|
|
38
|
-
cur = out
|
|
39
|
-
for p in parts[:-1]:
|
|
40
|
-
cur = cur.setdefault(p, {})
|
|
41
|
-
|
|
42
|
-
k = parts[-1]
|
|
43
|
-
is_list = get_origin(resolve_field_type(model_cls, parts)) is list
|
|
44
|
-
|
|
45
|
-
if is_list:
|
|
46
|
-
vals = val.split(",") if isinstance(val, str) and "," in val else [val]
|
|
47
|
-
cur.setdefault(k, []).extend(vals)
|
|
48
|
-
elif cur.get(k, val) != val:
|
|
49
|
-
raise ValueError(f"Duplicate value for {'.'.join(parts)}")
|
|
50
|
-
else:
|
|
51
|
-
cur[k] = val
|
|
52
|
-
|
|
53
|
-
while q:
|
|
54
|
-
t = q.popleft()
|
|
55
|
-
if not t.startswith("--"):
|
|
56
|
-
raise ValueError(f"Expected --key, got: {t}")
|
|
57
|
-
|
|
58
|
-
s = t[2:]
|
|
59
|
-
|
|
60
|
-
if s.startswith("no-"): # --no-flag
|
|
61
|
-
if "=" in s or has_value():
|
|
62
|
-
raise ValueError(f"--no-* flags can't take a value: {t}")
|
|
63
|
-
key, val = s[3:], False
|
|
64
|
-
elif "=" in s: # --k=v
|
|
65
|
-
key, val = s.split("=", 1)
|
|
66
|
-
else: # --k v / --flag
|
|
67
|
-
key, val = s, (q.popleft() if has_value() else True)
|
|
68
|
-
|
|
69
|
-
put(route(key), val)
|
|
70
|
-
|
|
71
|
-
return out
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def deep_merge(base: dict, overrides: dict) -> dict:
|
|
75
|
-
for k, v in overrides.items():
|
|
76
|
-
if isinstance(v, dict) and isinstance(base.get(k), dict):
|
|
77
|
-
deep_merge(base[k], v)
|
|
78
|
-
else:
|
|
79
|
-
base[k] = v
|
|
80
|
-
return base
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def model_help(model: type[BaseModel], prefix: str = "") -> list[str]:
|
|
84
|
-
def ty_name(ann) -> str:
|
|
85
|
-
name = getattr(ann, "__name__", None)
|
|
86
|
-
return name if name and "[" not in str(ann) else str(ann)
|
|
87
|
-
|
|
88
|
-
lines = []
|
|
89
|
-
for name, field in model.model_fields.items():
|
|
90
|
-
key = f"{prefix}{name}"
|
|
91
|
-
ann = field.annotation
|
|
92
|
-
if isinstance(ann, type) and issubclass(ann, BaseModel):
|
|
93
|
-
lines.extend(model_help(ann, prefix=f"{key}."))
|
|
94
|
-
else:
|
|
95
|
-
default = (
|
|
96
|
-
"required" if field.default is PydanticUndefined else field.default
|
|
97
|
-
)
|
|
98
|
-
desc = f" {field.description}" if field.description else ""
|
|
99
|
-
lines.append(f" --{key} {ty_name(ann)} (default: {default}){desc}")
|
|
100
|
-
return lines
|
|
101
|
-
|
|
File without changes
|