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.
@@ -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.1.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,3 @@
1
+ __all__ = ["cli"]
2
+
3
+ from .lib import cli
@@ -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)
@@ -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
-