Coverage for src / nanocli / core.py: 94%

270 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-19 03:47 -0500

1"""Core NanoCLI framework - CLI as a unified YAML tree. 

2 

3The CLI structure is a tree: 

4 - Root = entry point (YAML root) 

5 - Groups = non-leaf nodes (subtrees) 

6 - Commands = leaf nodes (functions with schemas) 

7 - Overrides = dotted paths into the tree 

8 

9No Click dependency - custom argument parser. 

10""" 

11 

12import sys 

13from collections.abc import Callable 

14from dataclasses import is_dataclass 

15from typing import Any, TypeVar 

16 

17from rich.console import Console 

18from rich.panel import Panel 

19 

20from nanocli.config import ( 

21 ConfigError, 

22 compile_config, 

23 get_schema_structure, 

24 load_yaml, 

25 to_yaml, 

26) 

27 

28T = TypeVar("T") 

29 

30 

31def parse_args(args: list[str]) -> tuple[list[str], list[str], dict[str, Any]]: 

32 """Parse CLI arguments into path, overrides, and flags. 

33 

34 Separates command path segments, key=value overrides, and special flags. 

35 

36 Args: 

37 args: List of CLI arguments. 

38 

39 Returns: 

40 Tuple of (path_parts, overrides, flags): 

41 - path_parts: List of path segments (e.g., ["data", "download"]) 

42 - overrides: List of key=value overrides 

43 - flags: Dict with "print", "print_global", "help", "cfg" 

44 

45 Examples: 

46 >>> path, overrides, flags = parse_args(["train", "epochs=100", "-p"]) 

47 >>> path 

48 ['train'] 

49 >>> overrides 

50 ['epochs=100'] 

51 >>> flags["print"] 

52 True 

53 """ 

54 path_parts = [] 

55 overrides = [] 

56 flags: dict[str, Any] = { 

57 "print": False, 

58 "print_global": False, 

59 "help": False, 

60 "cfg": None, 

61 } 

62 

63 i = 0 

64 while i < len(args): 

65 arg = args[i] 

66 

67 if arg in ("-p", "--print"): 

68 flags["print"] = True 

69 elif arg in ("-g", "--global"): 

70 flags["print_global"] = True 

71 elif arg in ("-h", "--help"): 

72 flags["help"] = True 

73 elif arg in ("-c", "--cfg"): 

74 if i + 1 < len(args): 

75 flags["cfg"] = args[i + 1] 

76 i += 1 

77 elif "=" in arg: 

78 overrides.append(arg) 

79 else: 

80 # Path segment (command or group name) 

81 path_parts.append(arg) 

82 

83 i += 1 

84 

85 return path_parts, overrides, flags 

86 

87 

88class NanoCLI: 

89 """Unified CLI tree for command groups and commands. 

90 

91 The CLI is structured as a tree where groups are non-leaf nodes 

92 and commands are leaf nodes with associated config schemas. 

93 

94 Attributes: 

95 _name: Name of this group. 

96 _help: Help text for this group. 

97 _parent: Parent NanoCLI instance (None for root). 

98 _commands: Registered commands. 

99 _groups: Nested groups. 

100 

101 Examples: 

102 >>> app = NanoCLI(name="myapp") 

103 >>> @app.command() 

104 ... def hello(cfg): 

105 ... print("Hello!") 

106 >>> "hello" in app._commands 

107 True 

108 """ 

109 

110 def __init__( 

111 self, 

112 name: str | None = None, 

113 help: str | None = None, 

114 parent: "NanoCLI | None" = None, 

115 ): 

116 self._name = name 

117 self._help = help 

118 self._parent = parent 

119 self._commands: dict[str, tuple[Callable[..., Any], type | None]] = {} 

120 self._groups: dict[str, NanoCLI] = {} 

121 

122 def __call__(self, args: list[str] | None = None) -> None: 

123 """Run the CLI.""" 

124 if args is None: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 args = sys.argv[1:] 

126 

127 self._execute(args) 

128 

129 def _get_root(self) -> "NanoCLI": 

130 """Get the root NanoCLI by traversing up the parent chain.""" 

131 current = self 

132 while current._parent is not None: 

133 current = current._parent 

134 return current 

135 

136 def _get_path(self) -> str: 

137 """Get the dotted path from root to this group.""" 

138 path_parts = [] 

139 current = self 

140 while current._parent is not None: 

141 if current._name: 141 ↛ 143line 141 didn't jump to line 143 because the condition on line 141 was always true

142 path_parts.append(current._name) 

143 current = current._parent 

144 return ".".join(reversed(path_parts)) 

145 

146 def _collect_configs( 

147 self, cfg_file: str | None, overrides: list[str], prefix: str = "" 

148 ) -> dict[str, Any]: 

149 """Recursively collect configs from all commands and groups.""" 

150 configs: dict[str, Any] = {} 

151 

152 for cmd_name, (_, schema) in self._commands.items(): 

153 if schema is not None: 153 ↛ 152line 153 didn't jump to line 152 because the condition on line 153 was always true

154 full_name = f"{prefix}{cmd_name}" if prefix else cmd_name 

155 cmd_overrides = [] 

156 for ov in overrides: 

157 if ov.startswith(f"{full_name}."): 

158 cmd_overrides.append(ov[len(full_name) + 1 :]) 

159 elif "." not in ov.split("=")[0]: 159 ↛ 156line 159 didn't jump to line 156 because the condition on line 159 was always true

160 cmd_overrides.append(ov) 

161 

162 base = load_yaml(cfg_file) if cfg_file else None 

163 config = compile_config(base=base, overrides=cmd_overrides, schema=schema) 

164 configs[cmd_name] = config 

165 

166 for group_name, sub_app in self._groups.items(): 

167 configs[group_name] = sub_app._collect_configs( 

168 cfg_file, overrides, f"{prefix}{group_name}." 

169 ) 

170 

171 return configs 

172 

173 def _execute(self, args: list[str]) -> None: 

174 """Execute the CLI with given arguments.""" 

175 path_parts, overrides, flags = parse_args(args) 

176 console = Console() 

177 

178 # Handle help at current level 

179 if flags["help"] and not path_parts: 

180 self._show_help(console) 

181 return 

182 

183 # Find the target node (traverse path) 

184 current = self 

185 consumed_path = [] 

186 

187 for part in path_parts: 

188 if part in current._groups: 

189 consumed_path.append(part) 

190 current = current._groups[part] 

191 elif part in current._commands: 

192 consumed_path.append(part) 

193 # Found a command - execute it 

194 func, schema = current._commands[part] 

195 

196 if flags["help"]: 

197 current._show_command_help(console, part, schema) 

198 return 

199 

200 # Get cfg file 

201 cfg_file = flags["cfg"] 

202 base = load_yaml(cfg_file) if cfg_file else None 

203 

204 # Compile config 

205 if schema: 

206 try: 

207 config = compile_config(base=base, overrides=overrides, schema=schema) 

208 except ConfigError as e: 

209 console.print(f"[red]Error:[/red] {e}") 

210 console.print() 

211 current._show_command_help(console, part, schema) 

212 sys.exit(1) 

213 else: 

214 config = None 

215 

216 if flags["print"]: 

217 # Print just this command's config 

218 if config: 218 ↛ 220line 218 didn't jump to line 220 because the condition on line 218 was always true

219 console.print(to_yaml(config), end="") 

220 return 

221 

222 if flags["print_global"]: 

223 # Print full tree from root 

224 root = current._get_root() 

225 full_path = ".".join(consumed_path) 

226 root_overrides = [f"{full_path}.{ov}" for ov in overrides] 

227 all_configs = root._collect_configs(cfg_file, root_overrides) 

228 console.print(to_yaml(all_configs), end="") 

229 return 

230 

231 # Execute the command 

232 if config: 

233 func(config) 

234 else: 

235 func() 

236 return 

237 else: 

238 console.print(f"[red]Error: Unknown command or group '{part}'[/red]") 

239 console.print( 

240 f"[dim]Available: {list(current._commands.keys()) + list(current._groups.keys())}[/dim]" 

241 ) 

242 sys.exit(1) 

243 

244 # No command found - we're at a group level 

245 if flags["help"]: 

246 current._show_help(console) 

247 return 

248 

249 if flags["print"] or flags["print_global"]: 

250 cfg_file = flags["cfg"] 

251 if flags["print_global"] and current._parent: 251 ↛ 253line 251 didn't jump to line 253 because the condition on line 251 was never true

252 # Print from root 

253 root = current._get_root() 

254 path = current._get_path() 

255 root_overrides = [f"{path}.{ov}" if path else ov for ov in overrides] 

256 all_configs = root._collect_configs(cfg_file, root_overrides) 

257 else: 

258 # Print from current node 

259 all_configs = current._collect_configs(cfg_file, overrides) 

260 console.print(to_yaml(all_configs), end="") 

261 return 

262 

263 # No command specified - show help 

264 console.print("[yellow]No command specified. Use -h to see available commands.[/yellow]") 

265 

266 def _show_help(self, console: Console) -> None: 

267 """Show help for this group.""" 

268 name = self._name or "app" 

269 

270 # Usage 

271 console.print( 

272 Panel( 

273 f"[bold]{name}[/bold] [cyan][OPTIONS][/cyan] [magenta]COMMAND[/magenta]", 

274 title="[bold blue]Usage[/bold blue]", 

275 border_style="blue", 

276 ) 

277 ) 

278 console.print() 

279 

280 # Description 

281 if self._help: 281 ↛ 286line 281 didn't jump to line 286 because the condition on line 281 was always true

282 console.print(self._help) 

283 console.print() 

284 

285 # Options 

286 if self._parent: 

287 options_text = ( 

288 " [cyan]-c, --cfg PATH[/cyan] Load config from YAML file\n" 

289 " [cyan]-p[/cyan] Print config and exit\n" 

290 " [cyan]-g[/cyan] Print root config (global) and exit\n" 

291 " [cyan]-h[/cyan] Show this help" 

292 ) 

293 else: 

294 options_text = ( 

295 " [cyan]-c, --cfg PATH[/cyan] Load config from YAML file\n" 

296 " [cyan]-p[/cyan] Print config and exit\n" 

297 " [cyan]-h[/cyan] Show this help" 

298 ) 

299 console.print( 

300 Panel( 

301 options_text, 

302 title="[bold green]Options[/bold green]", 

303 title_align="left", 

304 border_style="green", 

305 ) 

306 ) 

307 console.print() 

308 

309 # Commands 

310 all_items = list(self._commands.keys()) + list(self._groups.keys()) 

311 if all_items: 311 ↛ exitline 311 didn't return from function '_show_help' because the condition on line 311 was always true

312 command_lines = [] 

313 max_len = max(len(name) for name in all_items) + 4 

314 

315 for cmd_name, (func, _) in self._commands.items(): 

316 help_text = (func.__doc__ or "").strip().split("\n")[0] 

317 padding = " " * (max_len - len(cmd_name) - 2) 

318 command_lines.append( 

319 f" [magenta]{cmd_name}[/magenta]{padding}[dim]{help_text}[/dim]" 

320 ) 

321 

322 for group_name, sub_app in self._groups.items(): 

323 help_text = sub_app._help or "" 

324 padding = " " * (max_len - len(group_name) - 2) 

325 command_lines.append( 

326 f" [magenta]{group_name}[/magenta]{padding}[dim]{help_text}[/dim]" 

327 ) 

328 

329 console.print( 

330 Panel( 

331 "\n".join(command_lines), 

332 title="[bold magenta]Commands[/bold magenta]", 

333 title_align="left", 

334 border_style="magenta", 

335 ) 

336 ) 

337 

338 def _show_command_help(self, console: Console, name: str, schema: type | None) -> None: 

339 """Show help for a specific command.""" 

340 func, _ = self._commands[name] 

341 group_name = self._name or "app" 

342 

343 # Usage 

344 console.print( 

345 Panel( 

346 f"[bold]{group_name} {name}[/bold] [cyan][OPTIONS][/cyan]", 

347 title="[bold blue]Usage[/bold blue]", 

348 border_style="blue", 

349 ) 

350 ) 

351 console.print() 

352 

353 if func.__doc__: 

354 console.print(func.__doc__.strip()) 

355 console.print() 

356 

357 console.print("Override options with [cyan]key=value[/cyan]") 

358 console.print() 

359 

360 # Options 

361 if self._parent: 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true

362 options_text = ( 

363 " [cyan]-p[/cyan] Print compiled config and exit\n" 

364 " [cyan]-g[/cyan] Print root config (global) and exit\n" 

365 " [cyan]-h[/cyan] Show this help" 

366 ) 

367 else: 

368 options_text = ( 

369 " [cyan]-p[/cyan] Print compiled config and exit\n" 

370 " [cyan]-g[/cyan] Print root config (global) and exit\n" 

371 " [cyan]-h[/cyan] Show this help" 

372 ) 

373 console.print( 

374 Panel( 

375 options_text, 

376 title="[bold green]Options[/bold green]", 

377 title_align="left", 

378 border_style="green", 

379 ) 

380 ) 

381 console.print() 

382 

383 # Config 

384 if schema: 384 ↛ exitline 384 didn't return from function '_show_command_help' because the condition on line 384 was always true

385 _, config_opts = get_schema_structure(schema) 

386 if config_opts: 386 ↛ exitline 386 didn't return from function '_show_command_help' because the condition on line 386 was always true

387 config_lines = [] 

388 lengths = [] 

389 for name_opt, type_name, default, _ in config_opts: 

390 default_str = f" = {default}" if default is not None else "" 

391 lengths.append(len(f" {name_opt}: {type_name}{default_str}")) 

392 

393 max_len = max(lengths) if lengths else 0 

394 

395 for i, (name_opt, type_name, default, help_text) in enumerate(config_opts): 

396 default_str = f" [yellow]= {default}[/yellow]" if default is not None else "" 

397 padding = " " * (max_len - lengths[i] + 2) 

398 help_str = f"[dim]{help_text}[/dim]" if help_text else "" 

399 config_lines.append( 

400 f" [cyan]{name_opt}[/cyan]: [green]{type_name}[/green]{default_str}{padding}{help_str}" 

401 ) 

402 

403 console.print( 

404 Panel( 

405 "\n".join(config_lines), 

406 title="[bold cyan]Config[/bold cyan]", 

407 title_align="left", 

408 border_style="cyan", 

409 ) 

410 ) 

411 

412 def command( 

413 self, name: str | None = None 

414 ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: 

415 """Register a command.""" 

416 

417 def decorator(func: Callable[..., Any]) -> Callable[..., Any]: 

418 cmd_name = name or func.__name__ 

419 

420 # Infer schema from function signature 

421 from typing import get_type_hints 

422 

423 hints = get_type_hints(func) 

424 schema = None 

425 for pname, ptype in hints.items(): 

426 if pname != "return" and is_dataclass(ptype): 426 ↛ 425line 426 didn't jump to line 425 because the condition on line 426 was always true

427 schema = ptype 

428 break 

429 

430 self._commands[cmd_name] = (func, schema) # type: ignore[assignment] 

431 return func 

432 

433 return decorator 

434 

435 def group(self, name: str, help: str | None = None) -> "NanoCLI": 

436 """Create a nested group.""" 

437 sub_app = NanoCLI(name=name, help=help, parent=self) 

438 self._groups[name] = sub_app 

439 return sub_app 

440 

441 

442def group(name: str | None = None, help: str | None = None) -> NanoCLI: 

443 """Create a command group (CLI entry point). 

444 

445 This is the main entry point for creating a CLI application. 

446 

447 Args: 

448 name: Name of the CLI application. 

449 help: Help text shown in CLI help. 

450 

451 Returns: 

452 NanoCLI instance. 

453 

454 Examples: 

455 >>> app = group(name="myapp") 

456 >>> isinstance(app, NanoCLI) 

457 True 

458 >>> @app.command() 

459 ... def train(cfg): 

460 ... pass 

461 >>> "train" in app._commands 

462 True 

463 """ 

464 return NanoCLI(name=name, help=help) 

465 

466 

467def run( 

468 schema_or_func: type[T] | Any, 

469 args: list[str] | None = None, 

470) -> T | Any: 

471 """Run a single-command CLI from a config schema or function. 

472 

473 This provides a simple API for single-command CLIs without groups. 

474 

475 Args: 

476 schema_or_func: A dataclass type (returns compiled config) or 

477 a callable (infers schema from type hints and executes). 

478 args: CLI arguments. Defaults to sys.argv[1:]. 

479 

480 Returns: 

481 Compiled config if schema provided, or function return value. 

482 

483 Raises: 

484 ValueError: If function has no dataclass argument. 

485 

486 Examples: 

487 >>> from dataclasses import dataclass 

488 >>> @dataclass 

489 ... class Config: 

490 ... name: str = "world" 

491 >>> cfg = run(Config, args=[]) 

492 >>> cfg.name 

493 'world' 

494 """ 

495 from typing import get_type_hints 

496 

497 console = Console() 

498 

499 # Detect mode: Schema or Function 

500 func_to_run = None 

501 schema = schema_or_func 

502 

503 if not isinstance(schema_or_func, type) and callable(schema_or_func): 

504 func_to_run = schema_or_func 

505 # Infer schema from function signature 

506 hints = get_type_hints(func_to_run) 

507 found_schema = None 

508 for pname, ptype in hints.items(): 

509 if pname != "return" and is_dataclass(ptype): 509 ↛ 508line 509 didn't jump to line 508 because the condition on line 509 was always true

510 found_schema = ptype 

511 break 

512 

513 if not found_schema: 

514 raise ValueError( 

515 f"Could not infer config schema from function {func_to_run.__name__}. Ensure one argument is a dataclass." 

516 ) 

517 

518 schema = found_schema 

519 

520 if args is None: 

521 args = sys.argv[1:] 

522 

523 path_parts, overrides, flags = parse_args(args) 

524 

525 # Handle help 

526 if flags["help"]: 

527 _show_run_help(console, schema) 

528 return None 

529 

530 # Load base config 

531 cfg_file = flags["cfg"] 

532 base = load_yaml(cfg_file) if cfg_file else None 

533 

534 # Compile config 

535 try: 

536 config = compile_config(base=base, overrides=overrides, schema=schema) 

537 except ConfigError as e: 

538 console.print(f"[red]Error:[/red] {e}") 

539 console.print() 

540 _show_run_help(console, schema) 

541 sys.exit(1) 

542 

543 # Handle print 

544 if flags["print"] or flags["print_global"]: 

545 console.print(to_yaml(config), end="") 

546 return config 

547 

548 # Execute function if provided 

549 if func_to_run: 

550 return func_to_run(config) 

551 

552 return config 

553 

554 

555def _show_run_help(console: Console, schema: type) -> None: 

556 """Show help for run() API.""" 

557 name = "app" 

558 

559 # Usage 

560 console.print( 

561 Panel( 

562 f"[bold]{name}[/bold] [cyan][OPTIONS][/cyan]", 

563 title="[bold blue]Usage[/bold blue]", 

564 border_style="blue", 

565 ) 

566 ) 

567 console.print() 

568 

569 # Schema docstring 

570 if schema.__doc__: 570 ↛ 574line 570 didn't jump to line 574 because the condition on line 570 was always true

571 console.print(f"{schema.__name__} - {schema.__doc__.strip()}") 

572 console.print() 

573 

574 console.print("Override options with [cyan]key=value[/cyan]") 

575 console.print("Load YAML with [cyan]-c[/cyan], subtree with [cyan]key=@file.yml[/cyan]") 

576 console.print() 

577 

578 # Options 

579 options_text = ( 

580 " [cyan]-c, --cfg PATH[/cyan] Load config from YAML file\n" 

581 " [cyan]-p[/cyan] Print compiled config and exit\n" 

582 " [cyan]-h[/cyan] Show this help" 

583 ) 

584 console.print( 

585 Panel( 

586 options_text, 

587 title="[bold green]Options[/bold green]", 

588 title_align="left", 

589 border_style="green", 

590 ) 

591 ) 

592 console.print() 

593 

594 # Config 

595 _, config_opts = get_schema_structure(schema) 

596 if config_opts: 596 ↛ exitline 596 didn't return from function '_show_run_help' because the condition on line 596 was always true

597 config_lines = [] 

598 lengths = [] 

599 for name_opt, type_name, default, _ in config_opts: 

600 default_str = f" = {default}" if default is not None else "" 

601 lengths.append(len(f" {name_opt}: {type_name}{default_str}")) 

602 

603 max_len = max(lengths) if lengths else 0 

604 

605 for i, (name_opt, type_name, default, help_text) in enumerate(config_opts): 

606 default_str = f" [yellow]= {default}[/yellow]" if default is not None else "" 

607 padding = " " * (max_len - lengths[i] + 2) 

608 help_str = f"[dim]{help_text}[/dim]" if help_text else "" 

609 config_lines.append( 

610 f" [cyan]{name_opt}[/cyan]: [green]{type_name}[/green]{default_str}{padding}{help_str}" 

611 ) 

612 

613 console.print( 

614 Panel( 

615 "\n".join(config_lines), 

616 title="[bold cyan]Config[/bold cyan]", 

617 title_align="left", 

618 border_style="cyan", 

619 ) 

620 )