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

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

2

3 The 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

9 No Click dependency - custom argument parser.

10"""

11

12 import sys

13 from collections.abc import Callable

14 from dataclasses import is_dataclass

15 from typing import Any, TypeVar

16

17 from rich.console import Console

18 from rich.panel import Panel

19

20 from nanocli.config import (

21 ConfigError,

22 compile_config,

23 get_schema_structure,

24 load_yaml,

25 to_yaml,

26)

27

28 T = TypeVar("T")

29

30

31 def 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

88 class 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

442 def 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

467 def 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

555 def _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 )