cli-pydantic 0.1.0__py3-none-any.whl → 0.2.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.
cli_pydantic/__init__.py CHANGED
@@ -1 +1,3 @@
1
- from .lib import parse_unknown_args, deep_merge, model_help
1
+ __all__ = ["cli"]
2
+
3
+ from .lib import cli
cli_pydantic/lib.py CHANGED
@@ -1,9 +1,15 @@
1
+ import json
2
+ import sys
1
3
  from collections import deque
4
+ from pathlib import Path
2
5
  from typing import get_origin
3
6
 
7
+ import yaml
4
8
  from pydantic import BaseModel
5
9
  from pydantic_core import PydanticUndefined
6
10
 
11
+ __all__ = ["cli"]
12
+
7
13
 
8
14
  def resolve_field_type(model_cls: type[BaseModel], path: list[str]) -> type | None:
9
15
  """Walk a dotted path through nested BaseModels, return the leaf annotation."""
@@ -21,12 +27,8 @@ def resolve_field_type(model_cls: type[BaseModel], path: list[str]) -> type | No
21
27
  return None
22
28
 
23
29
 
24
- def parse_unknown_args(tokens: list[str], model_cls: type[BaseModel]) -> dict:
30
+ def parse_flags(tokens: list[str], model_cls: type[BaseModel]) -> dict:
25
31
  out = {}
26
- q = deque(tokens)
27
-
28
- def has_value() -> bool:
29
- return bool(q) and not q[0].startswith("--")
30
32
 
31
33
  def route(key: str) -> list[str]:
32
34
  parts = key.replace("-", "_").split(".")
@@ -50,6 +52,10 @@ def parse_unknown_args(tokens: list[str], model_cls: type[BaseModel]) -> dict:
50
52
  else:
51
53
  cur[k] = val
52
54
 
55
+ def has_value() -> bool:
56
+ return bool(q) and not q[0].startswith("--")
57
+
58
+ q = deque(tokens)
53
59
  while q:
54
60
  t = q.popleft()
55
61
  if not t.startswith("--"):
@@ -85,17 +91,93 @@ def model_help(model: type[BaseModel], prefix: str = "") -> list[str]:
85
91
  name = getattr(ann, "__name__", None)
86
92
  return name if name and "[" not in str(ann) else str(ann)
87
93
 
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
-
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)
@@ -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,6 @@
1
+ cli_pydantic/__init__.py,sha256=FzDWd6rL0-KFfomhD6Zfa_Au9y9g7jznspke55BFHbI,40
2
+ cli_pydantic/lib.py,sha256=vZsv9rSlYmL-aIDKD1Y-cRE3ElhkKzdI82cpeLVmDbY,5603
3
+ cli_pydantic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ cli_pydantic-0.2.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
5
+ cli_pydantic-0.2.0.dist-info/METADATA,sha256=oR2y5ZDl6-9xvRW7xoEV24iTkRhO4g2yTxrlk15kLdw,1556
6
+ cli_pydantic-0.2.0.dist-info/RECORD,,
@@ -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
-
@@ -1,6 +0,0 @@
1
- cli_pydantic/__init__.py,sha256=bQv5RB8buC5LQO5DNjDhl4ZudIi9SJFyOuesDCDWVro,60
2
- cli_pydantic/lib.py,sha256=V9cJVw5ZN9Cx4Y4Zy9WLx-dIdyUt9dEnr7ds-MRe3-8,3168
3
- cli_pydantic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- cli_pydantic-0.1.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
5
- cli_pydantic-0.1.0.dist-info/METADATA,sha256=Vxj2rtGtWVA76_iHL0kaFtADcn1UhMXL8olXvMiyxGA,281
6
- cli_pydantic-0.1.0.dist-info/RECORD,,