eggpipe 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eggpipe/cli/main.py ADDED
@@ -0,0 +1,710 @@
1
+ """Eggpipe command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import sys
8
+ import warnings
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, ParamSpec, TypeVar
13
+
14
+ import typer
15
+
16
+ from eggpipe.config.app_config import AppConfig
17
+ from eggpipe.config.logging import setup_logging
18
+ from eggpipe.config.project_config import ProjectConfig, ProjectConfigLoader
19
+ from eggpipe.core.discovery import discover_sources
20
+ from eggpipe.core.document import ModelDocument
21
+ from eggpipe.core.format_registry import FormatRegistry
22
+ from eggpipe.core.parser import Parser
23
+ from eggpipe.core.validator import validate_document
24
+ from eggpipe.engine.artifact_writer import ArtifactWriter
25
+ from eggpipe.engine.engine import Engine
26
+ from eggpipe.engine.plugin_loader import (
27
+ GENERATOR_GROUP,
28
+ PluginIssue,
29
+ build_format_registry_with_issues,
30
+ load_generators_with_issues,
31
+ load_local_generators_with_issues,
32
+ )
33
+ from eggpipe.engine.policy import DiagnosticPolicy
34
+ from eggpipe.engine.report import EngineReport
35
+ from eggpipe.interfaces.generator import GeneratorPlugin
36
+ from eggpipe.utils.exceptions import (
37
+ ConfigValidationError,
38
+ DocumentError,
39
+ EggpipeError,
40
+ )
41
+ from eggpipe.version import __version__
42
+ from eggpipe.watch.watcher import run_watch
43
+
44
+ app = typer.Typer(no_args_is_help=True, add_completion=False, help="Eggpipe")
45
+ config_app = typer.Typer(no_args_is_help=True, help="Configuration")
46
+ model_app = typer.Typer(no_args_is_help=True, help="Models")
47
+ gen_app = typer.Typer(no_args_is_help=True, help="Generators")
48
+ format_app = typer.Typer(no_args_is_help=True, help="Formats")
49
+ app.add_typer(config_app, name="config")
50
+ app.add_typer(model_app, name="model")
51
+ app.add_typer(gen_app, name="gen")
52
+ app.add_typer(format_app, name="format")
53
+
54
+ ConfigOption = Annotated[
55
+ Path | None,
56
+ typer.Option("--config", "-c", help="Path to .eggpipe.yaml"),
57
+ ]
58
+ GeneratorOption = Annotated[
59
+ list[str] | None,
60
+ typer.Option("--generator", "-g", help="Generators: comma-separated or repeated"),
61
+ ]
62
+
63
+
64
+ P = ParamSpec("P")
65
+ R = TypeVar("R")
66
+
67
+
68
+ def command_errors(func: Callable[P, R]) -> Callable[P, R]:
69
+ @functools.wraps(func)
70
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
71
+ try:
72
+ return func(*args, **kwargs)
73
+ except DocumentError as exc:
74
+ typer.echo(f"error: {exc}", err=True)
75
+ raise typer.Exit(code=1) from exc
76
+ except (ConfigValidationError, EggpipeError) as exc:
77
+ typer.echo(f"error: {exc}", err=True)
78
+ raise typer.Exit(code=2) from exc
79
+ except KeyboardInterrupt as exc:
80
+ raise typer.Exit(code=130) from exc
81
+
82
+ return wrapper
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ReportOptions:
87
+ output_format: str
88
+ quiet: bool
89
+ verbose: bool
90
+ strict: bool
91
+ ignore: str | None
92
+ json_lines: bool = False
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class RunOptions:
97
+ models: list[Path] | None
98
+ config: Path | None
99
+ generator: list[str] | None
100
+ plugins: list[Path] | None
101
+ output: Path | None
102
+ variables: list[str] | None
103
+ plan: bool
104
+ check: bool
105
+ force: bool
106
+ strict: bool
107
+ include_disabled: bool
108
+ allow_outside_imports: bool
109
+ clean: bool
110
+ output_format: str
111
+ ignore: str | None
112
+ quiet: bool
113
+ verbose: bool
114
+
115
+
116
+ def _load_rules(config_path: Path | None) -> tuple[ProjectConfig, Path]:
117
+ path = config_path or ProjectConfigLoader.discover()
118
+ if path is None:
119
+ raise ConfigValidationError(".eggpipe.yaml not found; pass --config")
120
+ rules = ProjectConfigLoader().load(path)
121
+ return rules, path.resolve().parent
122
+
123
+
124
+ def _selected_generators(values: list[str] | None) -> set[str] | None:
125
+ if not values:
126
+ return None
127
+ result: set[str] = set()
128
+ for value in values:
129
+ result.update(item.strip() for item in value.split(",") if item.strip())
130
+ return result or None
131
+
132
+
133
+ def _filter_rules(rules: ProjectConfig, selected: set[str] | None) -> ProjectConfig:
134
+ if selected is None:
135
+ return rules
136
+ known = {item.id for item in rules.generators}
137
+ unknown = selected - known
138
+ if unknown:
139
+ raise ConfigValidationError(f"unknown generators: {', '.join(sorted(unknown))}")
140
+ return rules.model_copy(
141
+ update={"generators": tuple(item for item in rules.generators if item.id in selected)}
142
+ )
143
+
144
+
145
+ def _parse_variables(values: list[str] | None) -> dict[str, str]:
146
+ result: dict[str, str] = {}
147
+ for item in values or []:
148
+ key, separator, value = item.partition("=")
149
+ if not separator or not key:
150
+ raise ConfigValidationError(f"invalid --var: {item!r}")
151
+ result[key] = value
152
+ return result
153
+
154
+
155
+ def _print_plugin_issues(issues: list[PluginIssue]) -> None:
156
+ for issue in issues:
157
+ typer.echo(f"warning: {issue.entry_point}: {issue.message}", err=True)
158
+
159
+
160
+ def _load_generators(plugins: list[Path] | None) -> dict[str, GeneratorPlugin]:
161
+ loaded, issues = load_generators_with_issues()
162
+ _print_plugin_issues(issues)
163
+ generators = dict(loaded)
164
+ if plugins:
165
+ local, local_issues = load_local_generators_with_issues(plugins)
166
+ _print_plugin_issues(local_issues)
167
+ generators.update(local)
168
+ return generators
169
+
170
+
171
+ def _load_format_registry() -> FormatRegistry:
172
+ registry, issues = build_format_registry_with_issues()
173
+ _print_plugin_issues(issues)
174
+ return registry
175
+
176
+
177
+ def _print_report(report: EngineReport, options: ReportOptions) -> int:
178
+ ignored = frozenset(item.strip() for item in (options.ignore or "").split(",") if item.strip())
179
+ diagnostics = DiagnosticPolicy(
180
+ ignore=ignored,
181
+ strict=options.strict,
182
+ ).apply(report.diagnostics)
183
+ warnings = [item for item in diagnostics if item.level == "warning"]
184
+ errors = [item for item in diagnostics if item.level == "error"]
185
+ success = (
186
+ not errors
187
+ and all(generator.status != "error" for generator in report.generators)
188
+ and not (options.strict and warnings)
189
+ )
190
+ exit_code = 0 if success else (2 if report.exit_code == 2 else 1)
191
+ report = report.model_copy(
192
+ update={"diagnostics": diagnostics, "success": success, "exit_code": exit_code}
193
+ )
194
+
195
+ if options.output_format == "json":
196
+ payload = report.model_dump(mode="json")
197
+ if options.json_lines:
198
+ typer.echo(json.dumps(payload, ensure_ascii=False))
199
+ else:
200
+ typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
201
+ return exit_code
202
+
203
+ for item in diagnostics:
204
+ if item.level == "error" or options.verbose or not options.quiet:
205
+ location = _location(item.location)
206
+ typer.echo(f"{item.level}: {item.code}: {item.message}{location}", err=True)
207
+ if not options.quiet:
208
+ for generator in report.generators:
209
+ typer.echo(f"[{generator.status}] {generator.id} ({generator.duration_ms} ms)")
210
+ if options.verbose:
211
+ for artifact in report.artifacts:
212
+ typer.echo(f" {artifact.action}: {artifact.path}")
213
+ typer.echo("OK" if success else "FAILED")
214
+ return exit_code
215
+
216
+
217
+ def _location(location: Any) -> str:
218
+ if location is None:
219
+ return ""
220
+ parts = [str(value) for value in (location.file, location.entity, location.field) if value]
221
+ return f" ({':'.join(parts)})" if parts else ""
222
+
223
+
224
+ def _run_engine(options: RunOptions) -> None:
225
+ if options.output_format not in {"text", "json"}:
226
+ raise ConfigValidationError("--output-format must be text or json")
227
+ rules, config_dir = _load_rules(options.config)
228
+ rules = _filter_rules(rules, _selected_generators(options.generator))
229
+ app_config = AppConfig(
230
+ rules_file=options.config,
231
+ output_root=options.output,
232
+ force=options.force,
233
+ include_disabled=options.include_disabled,
234
+ allow_outside_imports=options.allow_outside_imports,
235
+ clean=options.clean,
236
+ quiet=options.quiet,
237
+ verbose=options.verbose,
238
+ )
239
+ setup_logging(app_config)
240
+ engine = Engine(
241
+ app_config,
242
+ rules,
243
+ config_dir=config_dir,
244
+ source_paths=options.models,
245
+ variables=_parse_variables(options.variables),
246
+ generators=_load_generators(options.plugins),
247
+ formats=_load_format_registry(),
248
+ )
249
+ if options.check:
250
+ report = engine.check()
251
+ elif options.plan:
252
+ report = engine.build()
253
+ else:
254
+ report = engine.generate()
255
+ code = _print_report(
256
+ report,
257
+ ReportOptions(
258
+ output_format=options.output_format,
259
+ quiet=options.quiet,
260
+ verbose=options.verbose,
261
+ strict=options.strict,
262
+ ignore=options.ignore,
263
+ ),
264
+ )
265
+ raise typer.Exit(code=code)
266
+
267
+
268
+ @app.command()
269
+ @command_errors
270
+ def generate(
271
+ models: Annotated[
272
+ list[Path] | None, typer.Argument(help="Models (defaults to sources)")
273
+ ] = None,
274
+ config: ConfigOption = None,
275
+ generator: GeneratorOption = None,
276
+ plugin: Annotated[list[Path] | None, typer.Option("--plugin")] = None,
277
+ output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
278
+ variable: Annotated[list[str] | None, typer.Option("--var")] = None,
279
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
280
+ plan: Annotated[bool, typer.Option("--plan")] = False,
281
+ check: Annotated[bool, typer.Option("--check")] = False,
282
+ force: Annotated[bool, typer.Option("--force")] = False,
283
+ strict: Annotated[bool, typer.Option("--strict")] = False,
284
+ include_disabled: Annotated[bool, typer.Option("--include-disabled")] = False,
285
+ allow_outside_imports: Annotated[bool, typer.Option("--allow-outside-imports")] = False,
286
+ clean: Annotated[bool, typer.Option("--clean")] = False,
287
+ output_format: Annotated[str, typer.Option("--output-format")] = "text",
288
+ ignore: Annotated[str | None, typer.Option("--ignore")] = None,
289
+ quiet: Annotated[bool, typer.Option("--quiet", "-q")] = False,
290
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
291
+ no_color: Annotated[bool, typer.Option("--no-color")] = False,
292
+ ) -> None:
293
+ """Generate artifacts."""
294
+ del no_color
295
+ _run_engine(
296
+ RunOptions(
297
+ models=models,
298
+ config=config,
299
+ generator=generator,
300
+ plugins=plugin,
301
+ output=output,
302
+ variables=variable,
303
+ plan=plan or dry_run,
304
+ check=check,
305
+ force=force,
306
+ strict=strict,
307
+ include_disabled=include_disabled,
308
+ allow_outside_imports=allow_outside_imports,
309
+ clean=clean,
310
+ output_format=output_format,
311
+ ignore=ignore,
312
+ quiet=quiet,
313
+ verbose=verbose,
314
+ )
315
+ )
316
+
317
+
318
+ @app.command()
319
+ def init(
320
+ path: Annotated[Path, typer.Argument()] = Path(".eggpipe.yaml"),
321
+ force: Annotated[bool, typer.Option("--force")] = False,
322
+ layout: Annotated[str | None, typer.Option("--layout")] = None,
323
+ ) -> None:
324
+ """Create a minimal .eggpipe.yaml and an example model."""
325
+ if layout is None:
326
+ if not sys.stdin.isatty():
327
+ typer.echo("error: --layout is required in non-tty mode", err=True)
328
+ raise typer.Exit(code=2)
329
+ layout = typer.prompt(
330
+ "layout",
331
+ default="monolith",
332
+ type=str,
333
+ )
334
+ if layout not in {"monolith", "file-per-model", "directory-per-model"}:
335
+ typer.echo(f"error: unknown layout: {layout}", err=True)
336
+ raise typer.Exit(code=2)
337
+ if path.exists() and not force:
338
+ typer.echo(f"error: {path} already exists; use --force", err=True)
339
+ raise typer.Exit(code=2)
340
+ path.write_text(_default_config(layout), encoding="utf-8")
341
+ model_path = _default_model(path.parent, layout, force=force)
342
+ typer.echo(f"created {path}")
343
+ if model_path is not None:
344
+ typer.echo(f"created {model_path}")
345
+
346
+
347
+ def _default_model(base: Path, layout: str, *, force: bool) -> Path | None:
348
+ text = (
349
+ 'format: entity-model\nversion: "1.0"\nmodel:\n'
350
+ " entities:\n - name: User\n fields:\n"
351
+ " - name: id\n type: integer\n required: true\n"
352
+ " description: Identifier\n"
353
+ )
354
+ if layout == "monolith":
355
+ target = base / "model.yaml"
356
+ elif layout == "file-per-model":
357
+ target = base / "models" / "user.yaml"
358
+ else:
359
+ target = base / "models" / "user" / "base.yaml"
360
+ if target.exists() and not force:
361
+ return None
362
+ target.parent.mkdir(parents=True, exist_ok=True)
363
+ target.write_text(text, encoding="utf-8")
364
+ return target
365
+
366
+
367
+ def _default_config(layout: str) -> str:
368
+ if layout == "monolith":
369
+ source = " - path: ./model.yaml\n layout: monolith\n"
370
+ elif layout == "file-per-model":
371
+ source = ' - path: ./models\n layout: file-per-model\n files: ["*.yaml"]\n'
372
+ else:
373
+ source = (
374
+ " - path: ./models\n"
375
+ " layout: directory-per-model\n"
376
+ ' model_dirs: ["*"]\n'
377
+ ' files: ["*.yaml"]\n'
378
+ )
379
+ return f'version: "1.0"\nsources:\n{source}generators: []\n'
380
+
381
+
382
+ @app.command()
383
+ @command_errors
384
+ def watch(
385
+ models: Annotated[
386
+ list[Path] | None, typer.Argument(help="Models (defaults to sources)")
387
+ ] = None,
388
+ config: ConfigOption = None,
389
+ generator: GeneratorOption = None,
390
+ plugin: Annotated[list[Path] | None, typer.Option("--plugin")] = None,
391
+ output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
392
+ variable: Annotated[list[str] | None, typer.Option("--var")] = None,
393
+ strict: Annotated[bool, typer.Option("--strict")] = False,
394
+ include_disabled: Annotated[bool, typer.Option("--include-disabled")] = False,
395
+ allow_outside_imports: Annotated[bool, typer.Option("--allow-outside-imports")] = False,
396
+ force: Annotated[bool, typer.Option("--force")] = False,
397
+ output_format: Annotated[str, typer.Option("--output-format")] = "text",
398
+ ignore: Annotated[str | None, typer.Option("--ignore")] = None,
399
+ quiet: Annotated[bool, typer.Option("--quiet", "-q")] = False,
400
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
401
+ no_color: Annotated[bool, typer.Option("--no-color")] = False,
402
+ debounce: Annotated[int, typer.Option("--debounce-ms", "--debounce")] = 300,
403
+ ) -> None:
404
+ """Watch mode: regenerate when sources change."""
405
+ del no_color
406
+ path = config or ProjectConfigLoader.discover()
407
+ if path is None:
408
+ raise ConfigValidationError(".eggpipe.yaml not found; pass --config")
409
+ path = path.resolve()
410
+ rules, config_dir = _load_rules(path)
411
+ rules = _filter_rules(rules, _selected_generators(generator))
412
+ generators = _load_generators(plugin)
413
+ formats = _load_format_registry()
414
+ output_root = (output or config_dir).resolve()
415
+ source_paths = [
416
+ item if item.is_absolute() else (config_dir / item).resolve()
417
+ for item in (models or [source.path for source in rules.sources])
418
+ ]
419
+ discovered = discover_sources(
420
+ rules,
421
+ config_dir,
422
+ allow_outside=allow_outside_imports,
423
+ )
424
+ resource_paths = [item.path for item in discovered.resources.values()]
425
+ ignored_dirs = {(output_root / item.output_dir).resolve() for item in rules.generators}
426
+
427
+ def run_once() -> int:
428
+ current_rules, current_dir = _load_rules(path)
429
+ current_rules = _filter_rules(current_rules, _selected_generators(generator))
430
+ app_config = AppConfig(
431
+ rules_file=path,
432
+ output_root=output,
433
+ force=force,
434
+ include_disabled=include_disabled,
435
+ allow_outside_imports=allow_outside_imports,
436
+ quiet=quiet,
437
+ verbose=verbose,
438
+ )
439
+ setup_logging(app_config)
440
+ engine = Engine(
441
+ app_config,
442
+ current_rules,
443
+ config_dir=current_dir,
444
+ source_paths=models,
445
+ variables=_parse_variables(variable),
446
+ generators=generators,
447
+ formats=formats,
448
+ )
449
+ report = engine.generate()
450
+ return _print_report(
451
+ report,
452
+ ReportOptions(
453
+ output_format=output_format,
454
+ quiet=quiet,
455
+ verbose=verbose,
456
+ strict=strict,
457
+ ignore=ignore,
458
+ json_lines=True,
459
+ ),
460
+ )
461
+
462
+ run_watch(
463
+ config_path=path,
464
+ source_paths=[*source_paths, *resource_paths],
465
+ ignored_dirs=ignored_dirs,
466
+ debounce_ms=debounce,
467
+ run_once=run_once,
468
+ )
469
+
470
+
471
+ @config_app.command("show")
472
+ @command_errors
473
+ def config_show(
474
+ config: ConfigOption = None,
475
+ output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
476
+ show_secrets: Annotated[bool, typer.Option("--show-secrets")] = False,
477
+ ) -> None:
478
+ """Show the merged config and resolved paths."""
479
+ rules, config_dir = _load_rules(config)
480
+ data = rules.model_dump(mode="json", by_alias=True)
481
+ if not show_secrets:
482
+ data["variables"] = _redact(data.get("variables", {}))
483
+ discovered = discover_sources(rules, config_dir)
484
+ base = (output or config_dir).resolve()
485
+ data["resolved"] = {
486
+ "config_dir": str(config_dir),
487
+ "sources": [str(path) for path in discovered.paths],
488
+ "source_diagnostics": [item.model_dump(mode="json") for item in discovered.diagnostics],
489
+ "output_root": str(base),
490
+ "generators": [
491
+ {
492
+ "id": item.id,
493
+ "output_dir": str((base / item.output_dir).resolve()),
494
+ }
495
+ for item in rules.generators
496
+ ],
497
+ }
498
+ typer.echo(json.dumps(data, ensure_ascii=False, indent=2))
499
+
500
+
501
+ @config_app.command("validate")
502
+ @command_errors
503
+ def config_validate(config: ConfigOption = None) -> None:
504
+ """Validate .eggpipe.yaml and sources."""
505
+ rules, config_dir = _load_rules(config)
506
+ discovered = discover_sources(rules, config_dir)
507
+ for item in discovered.diagnostics:
508
+ typer.echo(f"{item.level}: {item.code}: {item.message}", err=True)
509
+ if any(item.level == "error" for item in discovered.diagnostics):
510
+ raise typer.Exit(code=1)
511
+ typer.echo("OK")
512
+
513
+
514
+ @config_app.command("schema")
515
+ def config_schema(
516
+ output: Annotated[Path | None, typer.Option("--output")] = None,
517
+ ) -> None:
518
+ """Print the .eggpipe.yaml JSON Schema."""
519
+ _print_schema(_safe_schema(ProjectConfig), output)
520
+
521
+
522
+ def _safe_schema(model: type[Any]) -> dict[str, Any]:
523
+ # UNSET is not a JSON value; default is excluded from the schema.
524
+ with warnings.catch_warnings():
525
+ warnings.simplefilter("ignore")
526
+ return model.model_json_schema()
527
+
528
+
529
+ def _print_schema(schema: dict[str, Any], output: Path | None) -> None:
530
+ text = json.dumps(schema, ensure_ascii=False, indent=2)
531
+ if output is not None:
532
+ output.write_text(text + "\n", encoding="utf-8")
533
+ typer.echo(str(output))
534
+ else:
535
+ typer.echo(text)
536
+
537
+
538
+ def _redact(values: dict[str, str]) -> dict[str, str]:
539
+ secret_markers = ("token", "secret", "password")
540
+ return {
541
+ key: "***" if any(marker in key.lower() for marker in secret_markers) else value
542
+ for key, value in values.items()
543
+ }
544
+
545
+
546
+ @model_app.command("validate")
547
+ def model_validate(
548
+ models: Annotated[list[Path], typer.Argument()],
549
+ output_format: Annotated[str, typer.Option("--output-format")] = "text",
550
+ ) -> None:
551
+ """Validate models."""
552
+ parser = Parser(_load_format_registry())
553
+ diagnostics = []
554
+ for path in models:
555
+ try:
556
+ diagnostics.extend(validate_document(parser.parse_document(path)))
557
+ except DocumentError as exc:
558
+ typer.echo(f"error: {exc.code}: {exc.message} ({exc.path})", err=True)
559
+ raise typer.Exit(code=1) from exc
560
+ if output_format == "json":
561
+ typer.echo(
562
+ json.dumps(
563
+ [item.model_dump(mode="json") for item in diagnostics],
564
+ ensure_ascii=False,
565
+ indent=2,
566
+ )
567
+ )
568
+ else:
569
+ for item in diagnostics:
570
+ typer.echo(f"{item.level}: {item.code}: {item.message}", err=True)
571
+ typer.echo("OK" if not any(item.level == "error" for item in diagnostics) else "FAILED")
572
+ raise typer.Exit(code=0 if not any(item.level == "error" for item in diagnostics) else 1)
573
+
574
+
575
+ @model_app.command("schema")
576
+ def model_schema(
577
+ output: Annotated[Path | None, typer.Option("--output")] = None,
578
+ format_name: Annotated[str | None, typer.Option("--format")] = None,
579
+ version: Annotated[str | None, typer.Option("--version")] = None,
580
+ ) -> None:
581
+ """Print the JSON Schema of a model or format document."""
582
+ if format_name is None:
583
+ _print_schema(_safe_schema(ModelDocument), output)
584
+ return
585
+ registry = _load_format_registry()
586
+ try:
587
+ plugin = registry.get(format_name)
588
+ except EggpipeError as exc:
589
+ typer.echo(f"error: {exc}", err=True)
590
+ raise typer.Exit(code=2) from exc
591
+ resolved = version or plugin.supported_versions[-1]
592
+ schema = plugin.schemas.get(resolved)
593
+ if schema is None:
594
+ typer.echo(f"error: {format_name} does not support version {resolved}", err=True)
595
+ raise typer.Exit(code=2)
596
+ _print_schema(
597
+ {
598
+ "format": format_name,
599
+ "version": resolved,
600
+ "layer_entity": _safe_schema(schema.layer_entity),
601
+ "full_entity": _safe_schema(schema.full_entity),
602
+ },
603
+ output,
604
+ )
605
+
606
+
607
+ @gen_app.command("list")
608
+ def gen_list(
609
+ show_all: Annotated[bool, typer.Option("--all")] = False,
610
+ ) -> None:
611
+ """List generators from entry points."""
612
+ plugins, issues = load_generators_with_issues()
613
+ if show_all:
614
+ _print_plugin_issues(issues)
615
+ if not plugins:
616
+ typer.echo("no generators")
617
+ return
618
+ for generator_id, plugin in sorted(plugins.items()):
619
+ formats = _input_formats_label(plugin.input_formats)
620
+ typer.echo(
621
+ f"{generator_id}\t{plugin.name or generator_id}\t"
622
+ f"api={plugin.api_version}\tinput_formats={formats}"
623
+ )
624
+
625
+
626
+ @gen_app.command("info")
627
+ def gen_info(generator_id: str) -> None:
628
+ """Show generator information."""
629
+ plugin = _load_generators(None).get(generator_id)
630
+ if plugin is None:
631
+ typer.echo(f"error: unknown generator: {generator_id}", err=True)
632
+ raise typer.Exit(code=2)
633
+ typer.echo(f"id: {plugin.id}")
634
+ typer.echo(f"name: {plugin.name or plugin.id}")
635
+ typer.echo(f"api_version: {plugin.api_version}")
636
+ typer.echo(f"input_formats: {_input_formats_label(plugin.input_formats)}")
637
+ typer.echo(f"entry point group: {GENERATOR_GROUP}")
638
+
639
+
640
+ def _input_formats_label(formats: set[str] | None) -> str:
641
+ if formats is None:
642
+ return "any"
643
+ return ",".join(sorted(formats)) if formats else "none"
644
+
645
+
646
+ @format_app.command("info")
647
+ def format_info(name: str) -> None:
648
+ """Show format information."""
649
+ registry = _load_format_registry()
650
+ try:
651
+ plugin = registry.get(name)
652
+ except EggpipeError as exc:
653
+ typer.echo(f"error: {exc}", err=True)
654
+ raise typer.Exit(code=2) from exc
655
+ typer.echo(f"name: {name}")
656
+ typer.echo(f"versions: {','.join(plugin.supported_versions)}")
657
+ typer.echo(f"extends: {plugin.extends or '-'}")
658
+ typer.echo(f"primitive_types: {','.join(sorted(plugin.primitive_types))}")
659
+ typer.echo(f"schemas: {','.join(sorted(plugin.schemas))}")
660
+
661
+
662
+ @format_app.command("list")
663
+ def format_list() -> None:
664
+ """List formats."""
665
+ registry, issues = build_format_registry_with_issues()
666
+ _print_plugin_issues(issues)
667
+ for item in registry.list():
668
+ typer.echo(f"{item.name}\tversions={','.join(item.versions)}")
669
+
670
+
671
+ @app.command()
672
+ @command_errors
673
+ def clean(
674
+ config: ConfigOption = None,
675
+ root: Annotated[Path | None, typer.Option("--root")] = None,
676
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
677
+ yes: Annotated[bool, typer.Option("--yes")] = False,
678
+ ) -> None:
679
+ """Remove unchanged owned artifacts using the global manifest."""
680
+ if root is not None:
681
+ config_dir = root.resolve()
682
+ elif config is not None:
683
+ config_dir = config.resolve().parent
684
+ else:
685
+ config_dir = Path.cwd()
686
+ if not dry_run and not yes:
687
+ if not sys.stdin.isatty():
688
+ typer.echo("error: --yes is required in non-tty mode", err=True)
689
+ raise typer.Exit(code=2)
690
+ if not typer.confirm("Remove unchanged owned artifacts?"):
691
+ return
692
+ writer = ArtifactWriter(eggpipe_version=__version__, config_dir=config_dir)
693
+ if not writer.manifest_path.is_file():
694
+ typer.echo("no manifest")
695
+ return
696
+ if dry_run:
697
+ result = writer.clean(dry_run=True)
698
+ else:
699
+ with writer.write_lock():
700
+ result = writer.clean(dry_run=False)
701
+ for item in result.diagnostics:
702
+ typer.echo(f"{item.level}: {item.code}: {item.message}", err=True)
703
+ action = "planned" if dry_run else "cleaned"
704
+ typer.echo(
705
+ f"{action} artifacts: {result.removed_artifacts}, locations: {result.removed_locations}"
706
+ )
707
+
708
+
709
+ if __name__ == "__main__":
710
+ app()