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
« 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.
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
9No Click dependency - custom argument parser.
10"""
12import sys
13from collections.abc import Callable
14from dataclasses import is_dataclass
15from typing import Any, TypeVar
17from rich.console import Console
18from rich.panel import Panel
20from nanocli.config import (
21 ConfigError,
22 compile_config,
23 get_schema_structure,
24 load_yaml,
25 to_yaml,
26)
28T = TypeVar("T")
31def parse_args(args: list[str]) -> tuple[list[str], list[str], dict[str, Any]]:
32 """Parse CLI arguments into path, overrides, and flags.
34 Separates command path segments, key=value overrides, and special flags.
36 Args:
37 args: List of CLI arguments.
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"
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 }
63 i = 0
64 while i < len(args):
65 arg = args[i]
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)
83 i += 1
85 return path_parts, overrides, flags
88class NanoCLI:
89 """Unified CLI tree for command groups and commands.
91 The CLI is structured as a tree where groups are non-leaf nodes
92 and commands are leaf nodes with associated config schemas.
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.
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 """
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] = {}
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:]
127 self._execute(args)
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
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))
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] = {}
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)
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
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 )
171 return configs
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()
178 # Handle help at current level
179 if flags["help"] and not path_parts:
180 self._show_help(console)
181 return
183 # Find the target node (traverse path)
184 current = self
185 consumed_path = []
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]
196 if flags["help"]:
197 current._show_command_help(console, part, schema)
198 return
200 # Get cfg file
201 cfg_file = flags["cfg"]
202 base = load_yaml(cfg_file) if cfg_file else None
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
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
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
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)
244 # No command found - we're at a group level
245 if flags["help"]:
246 current._show_help(console)
247 return
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
263 # No command specified - show help
264 console.print("[yellow]No command specified. Use -h to see available commands.[/yellow]")
266 def _show_help(self, console: Console) -> None:
267 """Show help for this group."""
268 name = self._name or "app"
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()
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()
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()
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
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 )
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 )
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 )
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"
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()
353 if func.__doc__:
354 console.print(func.__doc__.strip())
355 console.print()
357 console.print("Override options with [cyan]key=value[/cyan]")
358 console.print()
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()
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}"))
393 max_len = max(lengths) if lengths else 0
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 )
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 )
412 def command(
413 self, name: str | None = None
414 ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
415 """Register a command."""
417 def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
418 cmd_name = name or func.__name__
420 # Infer schema from function signature
421 from typing import get_type_hints
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
430 self._commands[cmd_name] = (func, schema) # type: ignore[assignment]
431 return func
433 return decorator
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
442def group(name: str | None = None, help: str | None = None) -> NanoCLI:
443 """Create a command group (CLI entry point).
445 This is the main entry point for creating a CLI application.
447 Args:
448 name: Name of the CLI application.
449 help: Help text shown in CLI help.
451 Returns:
452 NanoCLI instance.
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)
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.
473 This provides a simple API for single-command CLIs without groups.
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:].
480 Returns:
481 Compiled config if schema provided, or function return value.
483 Raises:
484 ValueError: If function has no dataclass argument.
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
497 console = Console()
499 # Detect mode: Schema or Function
500 func_to_run = None
501 schema = schema_or_func
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
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 )
518 schema = found_schema
520 if args is None:
521 args = sys.argv[1:]
523 path_parts, overrides, flags = parse_args(args)
525 # Handle help
526 if flags["help"]:
527 _show_run_help(console, schema)
528 return None
530 # Load base config
531 cfg_file = flags["cfg"]
532 base = load_yaml(cfg_file) if cfg_file else None
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)
543 # Handle print
544 if flags["print"] or flags["print_global"]:
545 console.print(to_yaml(config), end="")
546 return config
548 # Execute function if provided
549 if func_to_run:
550 return func_to_run(config)
552 return config
555def _show_run_help(console: Console, schema: type) -> None:
556 """Show help for run() API."""
557 name = "app"
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()
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()
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()
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()
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}"))
603 max_len = max(lengths) if lengths else 0
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 )
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 )