Coverage for sygaldry / cli.py: 73%

232 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 19:23 -0400

1from __future__ import annotations 

2 

3__author__ = "Rohan B. Dalton" 

4 

5import code 

6import json 

7from pathlib import Path 

8from typing import Any, Optional 

9 

10import rich_click as click 

11import yaml 

12from rich.console import Console 

13from rich.syntax import Syntax 

14 

15from .artificery import Artificery 

16from .checker import check as run_check 

17from .codegen import generate_check_source 

18from .errors import CLIError, SygaldryError 

19from .loader import _infer_scalar 

20 

21_console = Console(stderr=True) 

22 

23 

24def _parse_set_option(raw: str) -> tuple[str, Any]: 

25 """ 

26 Parse a ``--set key=value`` string. 

27 

28 :param raw: Raw option string. 

29 :type raw: str 

30 :returns: ``(dotted_path, coerced_value)`` pair. 

31 :raises CLIError: If the string has no ``=``. 

32 """ 

33 if "=" not in raw: 

34 raise CLIError(f"Invalid --set syntax (expected key=value): '{raw}'") 

35 else: 

36 key, _, value = raw.partition("=") 

37 if key := key.strip(): 

38 return key, _infer_scalar(value) 

39 else: 

40 raise CLIError(f"Invalid --set syntax (empty key): '{raw}'") 

41 

42 

43def _parse_use_option(raw: str) -> tuple[str, str]: 

44 """ 

45 Parse a ``--use target=source`` string. 

46 

47 :param raw: Raw option string. 

48 :type raw: str 

49 :returns: ``(target_path, source_path)`` pair. 

50 :raises CLIError: If the string has no ``=``. 

51 """ 

52 if "=" not in raw: 

53 raise CLIError(f"Invalid --use syntax (expected target=source): '{raw}'") 

54 target, _, source = raw.partition("=") 

55 target = target.strip() 

56 source = source.strip() 

57 if not target or not source: 

58 raise CLIError(f"Invalid --use syntax (empty path): '{raw}'") 

59 return target, source 

60 

61 

62def _build_artificery( 

63 config_paths: tuple[Path, ...], 

64 set_overrides: tuple[str, ...], 

65 use_overrides: tuple[str, ...], 

66 *, 

67 cache: Any | None = None, 

68 transient: bool = False, 

69) -> Artificery: 

70 """ 

71 Build an Artificery from CLI options. 

72 

73 :param config_paths: Config file paths. 

74 :param set_overrides: ``--set`` option values. 

75 :param use_overrides: ``--use`` option values. 

76 :param cache: Optional instance cache. 

77 :param transient: If True, bypass caching. 

78 :returns: Configured Artificery instance. 

79 """ 

80 overrides: dict[str, Any] = dict() 

81 for raw in set_overrides: 

82 key, value = _parse_set_option(raw) 

83 overrides[key] = value 

84 

85 uses: dict[str, str] = dict() 

86 for raw in use_overrides: 

87 target, source = _parse_use_option(raw) 

88 uses[target] = source 

89 

90 return Artificery( 

91 *config_paths, 

92 overrides=overrides or None, 

93 uses=uses or None, 

94 cache=cache, 

95 transient=transient, 

96 ) 

97 

98 

99def _extract_call_defaults( 

100 config: dict[str, Any], object_key: str 

101) -> tuple[str | None, list[Any]]: 

102 """ 

103 Extract ``_call`` defaults from the interpolated config. 

104 

105 :param config: Interpolated (pre-resolution) config. 

106 :param object_key: Top-level key for the target object. 

107 :type config: dict 

108 :type object_key: str 

109 :returns: ``(method_name_or_None, args_list)`` pair. 

110 """ 

111 obj_config = config.get(object_key) 

112 if not isinstance(obj_config, dict): 

113 return None, list() 

114 call = obj_config.get("_call") 

115 if not isinstance(call, dict): 

116 return None, list() 

117 method = call.get("method") 

118 args = call.get("args", []) 

119 if not isinstance(args, list): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true

120 args = [args] 

121 return method, args 

122 

123 

124def _parse_method_args(raw_args: tuple[str, ...]) -> list[Any]: 

125 """ 

126 Parse and coerce method arguments from CLI. 

127 

128 :param raw_args: Raw argument strings from after ``--``. 

129 :type raw_args: tuple[str, ...] 

130 :returns: Coerced argument list. 

131 """ 

132 return [_infer_scalar(arg) for arg in raw_args] 

133 

134 

135def _invoke_target(obj: Any, method_name: str | None, args: list[Any]) -> Any: 

136 """ 

137 Call a method or the object itself. 

138 

139 :param obj: Resolved target object. 

140 :param method_name: Method to call, or None for ``__call__``. 

141 :param args: Positional arguments. 

142 :type obj: object 

143 :type method_name: str | None 

144 :type args: list 

145 :returns: Return value of the call. 

146 :raises CLIError: If the object is not callable or method is missing. 

147 """ 

148 if method_name is not None: 

149 if not hasattr(obj, method_name): 

150 raise CLIError( 

151 f"Object {type(obj).__name__!r} has no method '{method_name}'. " 

152 f"Available attributes: {sorted(a for a in dir(obj) if not a.startswith('_'))}" 

153 ) 

154 else: 

155 target = getattr(obj, method_name) 

156 if not callable(target): 

157 raise CLIError( 

158 f"Attribute '{method_name}' on {type(obj).__name__!r} is not callable." 

159 ) 

160 else: 

161 return target(*args) 

162 else: 

163 if not callable(obj): 

164 raise CLIError( 

165 f"Object {type(obj).__name__!r} is not callable and no --method was specified." 

166 ) 

167 

168 return obj(*args) 

169 

170 

171def _format_config_yaml(config: Any) -> str: 

172 """ 

173 Format a config mapping as YAML. 

174 

175 :param config: Config mapping to format. 

176 :type config: object 

177 :returns: YAML string. 

178 """ 

179 return yaml.dump(config, default_flow_style=False, sort_keys=False) 

180 

181 

182def _print_dry_run( 

183 config_paths: tuple[Path, ...], 

184 object_key: str, 

185 method_name: str | None, 

186 args: list[Any], 

187 config: dict[str, Any], 

188) -> None: 

189 """ 

190 Print a dry-run summary. 

191 

192 :param config_paths: Config file paths that were loaded. 

193 :param object_key: Object key to resolve. 

194 :param method_name: Method to call. 

195 :param args: Method arguments. 

196 :param config: Interpolated config. 

197 """ 

198 _console.print("[bold]Dry run summary[/bold]") 

199 _console.print() 

200 _console.print(f" Config files: {', '.join(str(p) for p in config_paths)}") 

201 _console.print(f" Object: {object_key}") 

202 

203 call_desc = method_name or "__call__" 

204 _console.print(f" Method: {call_desc}") 

205 

206 if args: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 _console.print(f" Args: {args}") 

208 else: 

209 _console.print(" Args: (none)") 

210 

211 obj_config = config.get(object_key, {}) 

212 if isinstance(obj_config, dict): 212 ↛ exitline 212 didn't return from function '_print_dry_run' because the condition on line 212 was always true

213 _console.print() 

214 _console.print(f" [bold]Config for '{object_key}':[/bold]") 

215 yaml_str = _format_config_yaml(obj_config) 

216 syntax = Syntax(yaml_str, "yaml", theme="monokai", padding=1) 

217 _console.print(syntax) 

218 

219 

220def _config_options(func): 

221 """ 

222 Shared options for config loading, --set, and --use. 

223 """ 

224 func = click.option( 

225 "-c", 

226 "--config", 

227 "config_paths", 

228 multiple=True, 

229 required=True, 

230 type=click.Path(exists=True, dir_okay=False, path_type=Path), 

231 envvar="SYGALDRY_CONFIG", 

232 help="Config file path. Repeat for multiple files; deep-merged in order.", 

233 )(func) 

234 func = click.option( 

235 "--set", 

236 "set_overrides", 

237 multiple=True, 

238 help="Override a config value: dotted.path=value.", 

239 )(func) 

240 func = click.option( 

241 "--use", 

242 "use_overrides", 

243 multiple=True, 

244 help="Set a config value from another config path: target.path=source.path.", 

245 )(func) 

246 return func 

247 

248 

249@click.group() 

250@click.version_option(package_name="sygaldry") 

251def cli(): 

252 """ 

253 Sygaldry: build and run objects from config files. 

254 """ 

255 

256 

257@cli.command(context_settings={"ignore_unknown_options": True}) 

258@_config_options 

259@click.argument("object_key") 

260@click.option( 

261 "--method", 

262 "method_name", 

263 default=None, 

264 help="Method to call on the resolved object. Default: call the object itself.", 

265) 

266@click.option( 

267 "--dry-run", 

268 is_flag=True, 

269 help="Validate and show what would be called, without executing.", 

270) 

271@click.option("-v", "--verbose", is_flag=True, help="Show full tracebacks on error.") 

272@click.option("-q", "--quiet", is_flag=True, help="Suppress non-error output.") 

273@click.argument("method_args", nargs=-1, type=click.UNPROCESSED) 

274def run( 

275 config_paths: tuple[Path, ...], 

276 set_overrides: tuple[str, ...], 

277 use_overrides: tuple[str, ...], 

278 object_key: str, 

279 method_name: str | None, 

280 dry_run: bool, 

281 verbose: bool, 

282 quiet: bool, 

283 method_args: tuple[str, ...], 

284) -> None: 

285 """ 

286 Load config, resolve an object, and call it. 

287 """ 

288 try: 

289 art = _build_artificery(config_paths, set_overrides, use_overrides) 

290 call_method, call_args = _extract_call_defaults(art.config, object_key) 

291 

292 final_method = method_name if method_name is not None else call_method 

293 final_args = _parse_method_args(method_args) if method_args else call_args 

294 

295 if dry_run: 

296 _print_dry_run(config_paths, object_key, final_method, final_args, art.config) 

297 return 

298 else: 

299 resolved = art.resolve() 

300 

301 if object_key not in resolved: 

302 available = sorted(resolved.keys()) 

303 raise CLIError( 

304 f"Object '{object_key}' not found in resolved config. " 

305 f"Available keys: {available}" 

306 ) 

307 

308 target = resolved[object_key] 

309 result = _invoke_target(target, final_method, final_args) 

310 

311 if isinstance(result, int): 

312 raise SystemExit(result) 

313 if result is not None and not quiet: 

314 click.echo(result) 

315 

316 except SystemExit: 

317 raise 

318 except Exception as exc: 

319 if verbose: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 _console.print_exception() 

321 else: 

322 _console.print(f"[bold red]Error:[/bold red] {exc}") 

323 raise SystemExit(1) from None 

324 

325 

326@cli.command() 

327@_config_options 

328@click.option( 

329 "--object", 

330 "object_key", 

331 default=None, 

332 help="Show only this top-level key.", 

333) 

334@click.option( 

335 "--format", 

336 "output_format", 

337 type=click.Choice(["yaml", "json"]), 

338 default="yaml", 

339 help="Output format.", 

340) 

341@click.option( 

342 "--resolved", 

343 is_flag=True, 

344 help="Show resolved config (objects instantiated).", 

345) 

346@click.option( 

347 "--list-objects", 

348 is_flag=True, 

349 help="List available top-level config keys.", 

350) 

351@click.option( 

352 "--raw", 

353 is_flag=True, 

354 help="Show the merged YAML/JSON config instead of generated Python code.", 

355) 

356@click.option( 

357 "-o", 

358 "--output", 

359 "output_path", 

360 type=click.Path(dir_okay=False, path_type=Path), 

361 default=None, 

362 help="Write output to a file instead of stdout.", 

363) 

364def show( 

365 config_paths: tuple[Path, ...], 

366 set_overrides: tuple[str, ...], 

367 use_overrides: tuple[str, ...], 

368 object_key: str | None, 

369 output_format: str, 

370 resolved: bool, 

371 list_objects: bool, 

372 raw: bool, 

373 output_path: Path | None, 

374) -> None: 

375 """ 

376 Display the generated Python code for a config. 

377 

378 By default, shows the Python code that would be type-checked. 

379 Use --raw to see the merged YAML/JSON config instead. 

380 """ 

381 try: 

382 art = _build_artificery(config_paths, set_overrides, use_overrides) 

383 

384 if list_objects: 

385 for key in sorted(art.config.keys()): 

386 click.echo(key) 

387 return 

388 

389 if resolved: 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true

390 output = art.resolve() 

391 if object_key: 

392 if object_key not in output: 

393 available = sorted(output.keys()) 

394 raise CLIError( 

395 f"Object '{object_key}' not found. Available keys: {available}" 

396 ) 

397 click.echo(repr(output[object_key])) 

398 else: 

399 for key, value in output.items(): 

400 click.echo(f"{key}: {repr(value)}") 

401 return 

402 

403 if raw: 

404 display = art.config 

405 if object_key: 

406 if object_key not in display: 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true

407 available = sorted(display.keys()) 

408 raise CLIError( 

409 f"Object '{object_key}' not found. Available keys: {available}" 

410 ) 

411 display = display[object_key] 

412 

413 if output_format == "json": 

414 text = json.dumps(display, indent=2, default=str) 

415 else: 

416 text = _format_config_yaml(display) 

417 else: 

418 text, _ = generate_check_source(art.config) 

419 

420 if output_path: 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true

421 output_path.write_text(text) 

422 _console.print(f"Wrote output to [bold]{output_path}[/bold]") 

423 else: 

424 lexer = "yaml" if raw and output_format != "json" else "json" if raw else "python" 

425 syntax = Syntax(text, lexer, theme="monokai") 

426 Console().print(syntax) 

427 

428 except SystemExit: 

429 raise 

430 except SygaldryError as exc: 

431 _console.print(f"[bold red]Error:[/bold red] {exc}") 

432 raise SystemExit(1) from None 

433 

434 

435@cli.command() 

436@_config_options 

437def validate( 

438 config_paths: tuple[Path, ...], 

439 set_overrides: tuple[str, ...], 

440 use_overrides: tuple[str, ...], 

441) -> None: 

442 """ 

443 Validate config without executing. 

444 """ 

445 try: 

446 _build_artificery(config_paths, set_overrides, use_overrides).resolve() 

447 _console.print("[bold green]Config is valid.[/bold green]") 

448 

449 except SygaldryError as exc: 

450 _console.print(f"[bold red]Error:[/bold red] {exc}") 

451 raise SystemExit(1) from None 

452 

453 

454@cli.command() 

455@_config_options 

456@click.option( 

457 "--type-checker", 

458 "type_checker", 

459 type=click.Choice(["ty", "basedpyright", "pyright", "mypy"]), 

460 default=None, 

461 help="Type checker to use (auto-detected if omitted).", 

462) 

463@click.option("-v", "--verbose", is_flag=True, help="Show full tracebacks on error.") 

464def check( 

465 config_paths: tuple[Path, ...], 

466 set_overrides: tuple[str, ...], 

467 use_overrides: tuple[str, ...], 

468 type_checker: str | None, 

469 verbose: bool, 

470) -> None: 

471 """ 

472 Static type check a config file. 

473 """ 

474 

475 try: 

476 art = _build_artificery(config_paths, set_overrides, use_overrides) 

477 

478 errors = run_check(config=art.config, type_checker=type_checker) 

479 

480 if not errors: 

481 _console.print("[bold green]No type errors found.[/bold green]") 

482 return 

483 

484 for err in errors: 

485 label = err.severity.upper() 

486 _console.print(f"[bold red]{label}:[/bold red] {err.config_path}: {err.message}") 

487 

488 raise SystemExit(1) 

489 

490 except SystemExit: 

491 raise 

492 except Exception as exc: 

493 if verbose: 

494 _console.print_exception() 

495 else: 

496 _console.print(f"[bold red]Error:[/bold red] {exc}") 

497 raise SystemExit(1) from None 

498 

499 

500@cli.command() 

501@_config_options 

502@click.option("-v", "--verbose", is_flag=True, help="Show full tracebacks on error.") 

503def interactive( 

504 config_paths: tuple[Path, ...], 

505 set_overrides: tuple[str, ...], 

506 use_overrides: tuple[str, ...], 

507 verbose: bool, 

508) -> None: 

509 """ 

510 Start an interactive Python session with the Artificery loaded. 

511 """ 

512 try: 

513 art = _build_artificery(config_paths, set_overrides, use_overrides) 

514 # Eagerly prepare the config so errors surface before the REPL starts. 

515 _ = art.config 

516 

517 except Exception as exc: 

518 if verbose: 

519 _console.print_exception() 

520 else: 

521 _console.print(f"[bold red]Error:[/bold red] {exc}") 

522 raise SystemExit(1) from None 

523 

524 _console.print("[bold green]Sygaldry interactive session[/bold green]") 

525 _console.print() 

526 _console.print(" Config files: " + ", ".join(str(p) for p in config_paths)) 

527 _console.print(f" Top-level keys: {sorted(art.config.keys())}") 

528 _console.print() 

529 

530 namespace: dict[str, Any] = {"artificery": art, "Artificery": Artificery} 

531 

532 banner = "\n".join( 

533 [ 

534 "Available variables:", 

535 " artificery - Artificery instance (config loaded & merged)", 

536 "", 

537 "Quick start:", 

538 " artificery.config # view the interpolated config", 

539 " artificery.resolve() # resolve all objects", 

540 ] 

541 ) 

542 

543 try: 

544 from IPython import start_ipython 

545 

546 start_ipython(argv=[], user_ns=namespace, display_banner=False) 

547 except ImportError: 

548 code.interact(banner=banner, local=namespace, exitmsg="") 

549 

550 

551if __name__ == "__main__": 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true

552 cli()