Coverage for sygaldry / cli.py: 73%
232 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 19:23 -0400
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 19:23 -0400
1from __future__ import annotations
3__author__ = "Rohan B. Dalton"
5import code
6import json
7from pathlib import Path
8from typing import Any, Optional
10import rich_click as click
11import yaml
12from rich.console import Console
13from rich.syntax import Syntax
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
21_console = Console(stderr=True)
24def _parse_set_option(raw: str) -> tuple[str, Any]:
25 """
26 Parse a ``--set key=value`` string.
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}'")
43def _parse_use_option(raw: str) -> tuple[str, str]:
44 """
45 Parse a ``--use target=source`` string.
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
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.
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
85 uses: dict[str, str] = dict()
86 for raw in use_overrides:
87 target, source = _parse_use_option(raw)
88 uses[target] = source
90 return Artificery(
91 *config_paths,
92 overrides=overrides or None,
93 uses=uses or None,
94 cache=cache,
95 transient=transient,
96 )
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.
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
124def _parse_method_args(raw_args: tuple[str, ...]) -> list[Any]:
125 """
126 Parse and coerce method arguments from CLI.
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]
135def _invoke_target(obj: Any, method_name: str | None, args: list[Any]) -> Any:
136 """
137 Call a method or the object itself.
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 )
168 return obj(*args)
171def _format_config_yaml(config: Any) -> str:
172 """
173 Format a config mapping as YAML.
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)
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.
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}")
203 call_desc = method_name or "__call__"
204 _console.print(f" Method: {call_desc}")
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)")
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)
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
249@click.group()
250@click.version_option(package_name="sygaldry")
251def cli():
252 """
253 Sygaldry: build and run objects from config files.
254 """
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)
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
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()
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 )
308 target = resolved[object_key]
309 result = _invoke_target(target, final_method, final_args)
311 if isinstance(result, int):
312 raise SystemExit(result)
313 if result is not None and not quiet:
314 click.echo(result)
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
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.
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)
384 if list_objects:
385 for key in sorted(art.config.keys()):
386 click.echo(key)
387 return
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
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]
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)
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)
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
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]")
449 except SygaldryError as exc:
450 _console.print(f"[bold red]Error:[/bold red] {exc}")
451 raise SystemExit(1) from None
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 """
475 try:
476 art = _build_artificery(config_paths, set_overrides, use_overrides)
478 errors = run_check(config=art.config, type_checker=type_checker)
480 if not errors:
481 _console.print("[bold green]No type errors found.[/bold green]")
482 return
484 for err in errors:
485 label = err.severity.upper()
486 _console.print(f"[bold red]{label}:[/bold red] {err.config_path}: {err.message}")
488 raise SystemExit(1)
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
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
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
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()
530 namespace: dict[str, Any] = {"artificery": art, "Artificery": Artificery}
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 )
543 try:
544 from IPython import start_ipython
546 start_ipython(argv=[], user_ns=namespace, display_banner=False)
547 except ImportError:
548 code.interact(banner=banner, local=namespace, exitmsg="")
551if __name__ == "__main__": 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true
552 cli()