Coverage for sygaldry / cli.py: 74%

264 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 18:33 -0400

1from __future__ import annotations 

2 

3__author__ = "Rohan B. Dalton" 

4 

5import asyncio 

6import code 

7import inspect 

8import json 

9from pathlib import Path 

10from typing import Any, Optional 

11 

12import rich_click as click 

13import yaml 

14from rich.console import Console 

15from rich.syntax import Syntax 

16 

17from .artificery import Artificery 

18from .checker import check as run_check 

19from .codegen import generate_check_source 

20from .errors import CLIError, SygaldryError 

21from .loader import _infer_scalar, _is_url 

22 

23_console = Console(stderr=True) 

24 

25 

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

27 """ 

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

29 

30 :param raw: Raw option string. 

31 :type raw: str 

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

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

34 """ 

35 if "=" not in raw: 

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

37 else: 

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

39 if key := key.strip(): 

40 return key, _infer_scalar(value) 

41 else: 

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

43 

44 

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

46 """ 

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

48 

49 :param raw: Raw option string. 

50 :type raw: str 

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

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

53 """ 

54 if "=" not in raw: 

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

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

57 target = target.strip() 

58 source = source.strip() 

59 if not target or not source: 

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

61 return target, source 

62 

63 

64def _build_artificery( 

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

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

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

68 *, 

69 cache: Any | None = None, 

70 transient: bool = False, 

71) -> Artificery: 

72 """ 

73 Build an Artificery from CLI options. 

74 

75 :param config_paths: Config file paths. 

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

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

78 :param cache: Optional instance cache. 

79 :param transient: If True, bypass caching. 

80 :returns: Configured Artificery instance. 

81 """ 

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

83 for raw in set_overrides: 

84 key, value = _parse_set_option(raw) 

85 overrides[key] = value 

86 

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

88 for raw in use_overrides: 

89 target, source = _parse_use_option(raw) 

90 uses[target] = source 

91 

92 return Artificery( 

93 *config_paths, 

94 overrides=overrides or None, 

95 uses=uses or None, 

96 cache=cache, 

97 transient=transient, 

98 ) 

99 

100 

101def _extract_call_defaults( 

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

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

104 """ 

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

106 

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

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

109 :type config: dict 

110 :type object_key: str 

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

112 """ 

113 obj_config = config.get(object_key) 

114 if not isinstance(obj_config, dict): 

115 return None, list() 

116 call = obj_config.get("_call") 

117 if not isinstance(call, dict): 

118 return None, list() 

119 method = call.get("method") 

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

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

122 args = [args] 

123 return method, args 

124 

125 

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

127 """ 

128 Parse and coerce method arguments from CLI. 

129 

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

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

132 :returns: Coerced argument list. 

133 """ 

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

135 

136 

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

138 """ 

139 Call a method or the object itself. 

140 

141 :param obj: Resolved target object. 

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

143 :param args: Positional arguments. 

144 :type obj: object 

145 :type method_name: str | None 

146 :type args: list 

147 :returns: Return value of the call. 

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

149 """ 

150 if method_name is not None: 

151 if not hasattr(obj, method_name): 

152 raise CLIError( 

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

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

155 ) 

156 else: 

157 target = getattr(obj, method_name) 

158 if not callable(target): 

159 raise CLIError( 

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

161 ) 

162 else: 

163 if inspect.iscoroutinefunction(target): 

164 return asyncio.run(target(*args)) 

165 return target(*args) 

166 else: 

167 if not callable(obj): 

168 raise CLIError( 

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

170 ) 

171 

172 if inspect.iscoroutinefunction(getattr(obj, "__call__", obj)): 

173 return asyncio.run(obj(*args)) 

174 return obj(*args) 

175 

176 

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

178 """ 

179 Format a config mapping as YAML. 

180 

181 :param config: Config mapping to format. 

182 :type config: object 

183 :returns: YAML string. 

184 """ 

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

186 

187 

188def _print_dry_run( 

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

190 object_key: str, 

191 method_name: str | None, 

192 args: list[Any], 

193 config: dict[str, Any], 

194) -> None: 

195 """ 

196 Print a dry-run summary. 

197 

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

199 :param object_key: Object key to resolve. 

200 :param method_name: Method to call. 

201 :param args: Method arguments. 

202 :param config: Interpolated config. 

203 """ 

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

205 _console.print() 

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

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

208 

209 call_desc = method_name or "__call__" 

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

211 

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

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

214 else: 

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

216 

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

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

219 _console.print() 

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

221 yaml_str = _format_config_yaml(obj_config) 

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

223 _console.print(syntax) 

224 

225 

226class _ConfigPathOrURL(click.ParamType): 

227 """Click parameter type that accepts a local file path or an HTTP(S) URL.""" 

228 

229 name = "CONFIG" 

230 

231 def convert(self, value, param, ctx): 

232 if isinstance(value, Path): 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 return value 

234 if _is_url(value): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 return value 

236 path = Path(value) 

237 if not path.exists(): 

238 self.fail(f"Path '{value}' does not exist.", param, ctx) 

239 if path.is_dir(): 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 self.fail(f"Path '{value}' is a directory.", param, ctx) 

241 return path 

242 

243 

244def _config_options(func): 

245 """ 

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

247 """ 

248 func = click.option( 

249 "-c", 

250 "--config", 

251 "config_paths", 

252 multiple=True, 

253 required=True, 

254 type=_ConfigPathOrURL(), 

255 envvar="SYGALDRY_CONFIG", 

256 help="Config file path or HTTP(S) URL. Repeat for multiple; deep-merged in order.", 

257 )(func) 

258 func = click.option( 

259 "--set", 

260 "set_overrides", 

261 multiple=True, 

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

263 )(func) 

264 func = click.option( 

265 "--use", 

266 "use_overrides", 

267 multiple=True, 

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

269 )(func) 

270 return func 

271 

272 

273@click.group() 

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

275def cli(): 

276 """ 

277 Sygaldry: build and run objects from config files. 

278 """ 

279 

280 

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

282@_config_options 

283@click.argument("object_key") 

284@click.option( 

285 "--method", 

286 "method_name", 

287 default=None, 

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

289) 

290@click.option( 

291 "--dry-run", 

292 is_flag=True, 

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

294) 

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

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

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

298def run( 

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

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

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

302 object_key: str, 

303 method_name: str | None, 

304 dry_run: bool, 

305 verbose: bool, 

306 quiet: bool, 

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

308) -> None: 

309 """ 

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

311 """ 

312 try: 

313 art = _build_artificery(config_paths, set_overrides, use_overrides) 

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

315 

316 final_method = method_name if method_name is not None else call_method 

317 final_args = _parse_method_args(method_args) if method_args else call_args 

318 

319 if dry_run: 

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

321 return 

322 else: 

323 resolved = art.resolve() 

324 

325 if object_key not in resolved: 

326 available = sorted(resolved.keys()) 

327 raise CLIError( 

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

329 f"Available keys: {available}" 

330 ) 

331 

332 target = resolved[object_key] 

333 result = _invoke_target(target, final_method, final_args) 

334 

335 if isinstance(result, int): 

336 raise SystemExit(result) 

337 if result is not None and not quiet: 

338 click.echo(result) 

339 

340 except SystemExit: 

341 raise 

342 except Exception as exc: 

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

344 _console.print_exception() 

345 else: 

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

347 raise SystemExit(1) from None 

348 

349 

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

351@_config_options 

352@click.argument("object_key", required=False, default=None) 

353@click.option( 

354 "--object", 

355 "object_key_opt", 

356 default=None, 

357 help="Show only this top-level key (also accepted as a positional argument).", 

358) 

359@click.option( 

360 "--method", 

361 "method_name", 

362 default=None, 

363 help="Method to show being called on the resolved object.", 

364) 

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

366@click.option( 

367 "--format", 

368 "output_format", 

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

370 default="yaml", 

371 help="Output format.", 

372) 

373@click.option( 

374 "--resolved", 

375 is_flag=True, 

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

377) 

378@click.option( 

379 "--list-objects", 

380 is_flag=True, 

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

382) 

383@click.option( 

384 "--raw", 

385 is_flag=True, 

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

387) 

388@click.option( 

389 "-o", 

390 "--output", 

391 "output_path", 

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

393 default=None, 

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

395) 

396@click.option( 

397 "--line-length", 

398 "line_length", 

399 type=int, 

400 default=99, 

401 show_default=True, 

402 help="Maximum line length for generated Python code.", 

403) 

404def show( 

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

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

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

408 object_key: str | None, 

409 object_key_opt: str | None, 

410 method_name: str | None, 

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

412 output_format: str, 

413 resolved: bool, 

414 list_objects: bool, 

415 raw: bool, 

416 output_path: Path | None, 

417 line_length: int, 

418) -> None: 

419 """ 

420 Display the generated Python code for a config. 

421 

422 Optionally pass OBJECT_KEY to also show the call expression for that object. 

423 

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

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

426 """ 

427 object_key = object_key or object_key_opt 

428 try: 

429 art = _build_artificery(config_paths, set_overrides, use_overrides) 

430 

431 if list_objects: 

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

433 click.echo(key) 

434 return 

435 

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

437 output = art.resolve() 

438 if object_key: 

439 if object_key not in output: 

440 available = sorted(output.keys()) 

441 raise CLIError( 

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

443 ) 

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

445 else: 

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

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

448 return 

449 

450 if raw: 

451 display = art.config 

452 if object_key: 

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

454 available = sorted(display.keys()) 

455 raise CLIError( 

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

457 ) 

458 display = display[object_key] 

459 

460 if output_format == "json": 

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

462 else: 

463 text = _format_config_yaml(display) 

464 else: 

465 call_target = None 

466 call_method = None 

467 call_args: list[Any] | None = None 

468 

469 if object_key: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true

470 call_target = object_key 

471 cfg_method, cfg_args = _extract_call_defaults(art.config, object_key) 

472 call_method = method_name if method_name is not None else cfg_method 

473 call_args = _parse_method_args(method_args) if method_args else cfg_args 

474 

475 text, _ = generate_check_source( 

476 art.config, 

477 line_length=line_length, 

478 call_target=call_target, 

479 call_method=call_method, 

480 call_args=call_args or None, 

481 ) 

482 

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

484 output_path.write_text(text) 

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

486 else: 

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

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

489 Console().print(syntax) 

490 

491 except SystemExit: 

492 raise 

493 except SygaldryError as exc: 

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

495 raise SystemExit(1) from None 

496 

497 

498@cli.command() 

499@_config_options 

500def validate( 

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

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

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

504) -> None: 

505 """ 

506 Validate config without executing. 

507 """ 

508 try: 

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

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

511 

512 except SygaldryError as exc: 

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

514 raise SystemExit(1) from None 

515 

516 

517@cli.command() 

518@_config_options 

519@click.option( 

520 "--type-checker", 

521 "type_checker", 

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

523 default=None, 

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

525) 

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

527def check( 

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

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

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

531 type_checker: str | None, 

532 verbose: bool, 

533) -> None: 

534 """ 

535 Static type check a config file. 

536 """ 

537 

538 try: 

539 art = _build_artificery(config_paths, set_overrides, use_overrides) 

540 

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

542 

543 if not errors: 

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

545 return 

546 

547 for err in errors: 

548 label = err.severity.upper() 

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

550 

551 raise SystemExit(1) 

552 

553 except SystemExit: 

554 raise 

555 except Exception as exc: 

556 if verbose: 

557 _console.print_exception() 

558 else: 

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

560 raise SystemExit(1) from None 

561 

562 

563@cli.command() 

564@_config_options 

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

566def interactive( 

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

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

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

570 verbose: bool, 

571) -> None: 

572 """ 

573 Start an interactive Python session with the Artificery loaded. 

574 """ 

575 try: 

576 art = _build_artificery(config_paths, set_overrides, use_overrides) 

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

578 _ = art.config 

579 

580 except Exception as exc: 

581 if verbose: 

582 _console.print_exception() 

583 else: 

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

585 raise SystemExit(1) from None 

586 

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

588 _console.print() 

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

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

591 _console.print() 

592 

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

594 

595 banner = "\n".join( 

596 [ 

597 "Available variables:", 

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

599 "", 

600 "Quick start:", 

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

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

603 ] 

604 ) 

605 

606 try: 

607 from IPython import start_ipython 

608 

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

610 except ImportError: 

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

612 

613 

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

615 cli()