cli-pydantic 0.1.0__tar.gz → 0.2.1__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.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.3
2
+ Name: cli-pydantic
3
+ Version: 0.2.1
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
+ from pydantic import BaseModel, Field
29
+ from cli_pydantic import cli
30
+
31
+ class Data(BaseModel):
32
+ path: str = "./data"
33
+ splits: list[str] = ["train", "val"]
34
+
35
+ class Model(BaseModel):
36
+ arch: str = "resnet50"
37
+ lr: float = 1e-3
38
+ layers: list[int] = [64, 128, 256]
39
+
40
+ class Config(BaseModel):
41
+ data: Data = Data()
42
+ model: Model = Model()
43
+ epochs: int = 10
44
+ profile: bool = Field(False, description="dump chrome trace")
45
+
46
+ cfg = cli(Config, desc="Training pipeline")
47
+ ```
48
+
49
+ ```bash
50
+ # CLI, use default from Pydantic
51
+ python train.py --model.arch vit_base --model.lr 3e-4 --epochs 50
52
+
53
+ # From a config file
54
+ python train.py base.yaml
55
+
56
+ # Layer multiple configs, then override with flags
57
+ python train.py base.yaml fast.yaml --model.lr 0.05 --epochs 3
58
+
59
+ # Lists and booleans
60
+ python train.py --model.layers 16,32 --profile
61
+ ```
62
+
63
+ ## Semantics:
64
+
65
+ - Use `--foo bar` or `--foo=bar`
66
+ - For lists: `--nums=1,2,3` or `--nums 1 --nums=2 --nums 3`
67
+ - For bools: `--enable` / `--no-enable`
68
+ - Lists will _replace_ previous configs on override -- not append!
69
+
70
+ ## Automatic help
71
+
72
+ ```bash
73
+ $ python train.py --help
74
+ help: Training pipeline
75
+
76
+ usage: train.py [-h] [configs ...] [--overrides ...]
77
+
78
+ config arguments:
79
+ --data.path str (default: ./data)
80
+ --data.splits list[str] (default: ['train', 'val'])
81
+ --model.arch str (default: resnet50)
82
+ --model.lr float (default: 0.001)
83
+ --model.layers list[int] (default: [64, 128, 256])
84
+ --epochs int (default: 10)
85
+ --verbose bool dump chrome trace (default: False)
86
+ ```
@@ -0,0 +1,75 @@
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
+ from pydantic import BaseModel, Field
18
+ from cli_pydantic import cli
19
+
20
+ class Data(BaseModel):
21
+ path: str = "./data"
22
+ splits: list[str] = ["train", "val"]
23
+
24
+ class Model(BaseModel):
25
+ arch: str = "resnet50"
26
+ lr: float = 1e-3
27
+ layers: list[int] = [64, 128, 256]
28
+
29
+ class Config(BaseModel):
30
+ data: Data = Data()
31
+ model: Model = Model()
32
+ epochs: int = 10
33
+ profile: bool = Field(False, description="dump chrome trace")
34
+
35
+ cfg = cli(Config, desc="Training pipeline")
36
+ ```
37
+
38
+ ```bash
39
+ # CLI, use default from Pydantic
40
+ python train.py --model.arch vit_base --model.lr 3e-4 --epochs 50
41
+
42
+ # From a config file
43
+ python train.py base.yaml
44
+
45
+ # Layer multiple configs, then override with flags
46
+ python train.py base.yaml fast.yaml --model.lr 0.05 --epochs 3
47
+
48
+ # Lists and booleans
49
+ python train.py --model.layers 16,32 --profile
50
+ ```
51
+
52
+ ## Semantics:
53
+
54
+ - Use `--foo bar` or `--foo=bar`
55
+ - For lists: `--nums=1,2,3` or `--nums 1 --nums=2 --nums 3`
56
+ - For bools: `--enable` / `--no-enable`
57
+ - Lists will _replace_ previous configs on override -- not append!
58
+
59
+ ## Automatic help
60
+
61
+ ```bash
62
+ $ python train.py --help
63
+ help: Training pipeline
64
+
65
+ usage: train.py [-h] [configs ...] [--overrides ...]
66
+
67
+ config arguments:
68
+ --data.path str (default: ./data)
69
+ --data.splits list[str] (default: ['train', 'val'])
70
+ --model.arch str (default: resnet50)
71
+ --model.lr float (default: 0.001)
72
+ --model.layers list[int] (default: [64, 128, 256])
73
+ --epochs int (default: 10)
74
+ --verbose bool dump chrome trace (default: False)
75
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cli-pydantic"
3
- version = "0.1.0"
3
+ version = "0.2.1"
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,3 @@
1
+ __all__ = ["cli"]
2
+
3
+ from .lib import cli
@@ -0,0 +1,184 @@
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 = f" {field.description}" if field.description else ""
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) + 1
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 = []
156
+ if desc:
157
+ lines.append(f"help: {desc}\n")
158
+ lines.append(f"usage: {prog} [-h] [configs ...] [--overrides ...]")
159
+ lines.append("\nconfig arguments:")
160
+ lines.extend(model_help(model_cls))
161
+ print("\n".join(lines))
162
+ raise SystemExit(0)
163
+
164
+ def split_argv() -> tuple[list[Path], list[str]]:
165
+ config_paths: list[Path] = []
166
+ for i, tok in enumerate(argv):
167
+ if tok.startswith("-"):
168
+ return config_paths, argv[i:]
169
+ config_paths.append(Path(tok))
170
+ return config_paths, []
171
+
172
+ if "-h" in argv or "--help" in argv:
173
+ print_help()
174
+
175
+ config_paths, flag_tokens = split_argv()
176
+
177
+ configs = [load_config(p) for p in config_paths]
178
+ overrides = parse_flags(flag_tokens, model_cls)
179
+
180
+ data = {}
181
+ for new in configs + [overrides]:
182
+ deep_merge(data, new)
183
+
184
+ return model_cls.model_validate(data)
@@ -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
-
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
-