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/__init__.py +129 -0
- eggpipe/cli/__init__.py +0 -0
- eggpipe/cli/main.py +710 -0
- eggpipe/config/__init__.py +0 -0
- eggpipe/config/app_config.py +35 -0
- eggpipe/config/logging.py +40 -0
- eggpipe/config/project_config.py +162 -0
- eggpipe/core/__init__.py +0 -0
- eggpipe/core/composition.py +431 -0
- eggpipe/core/discovery.py +566 -0
- eggpipe/core/document.py +32 -0
- eggpipe/core/format_registry.py +230 -0
- eggpipe/core/layers.py +385 -0
- eggpipe/core/model.py +308 -0
- eggpipe/core/naming.py +36 -0
- eggpipe/core/parser.py +96 -0
- eggpipe/core/paths.py +12 -0
- eggpipe/core/provenance.py +52 -0
- eggpipe/core/slicing.py +110 -0
- eggpipe/core/validator.py +518 -0
- eggpipe/engine/__init__.py +0 -0
- eggpipe/engine/artifact_writer.py +741 -0
- eggpipe/engine/engine.py +679 -0
- eggpipe/engine/plugin_loader.py +215 -0
- eggpipe/engine/policy.py +27 -0
- eggpipe/engine/report.py +90 -0
- eggpipe/interfaces/__init__.py +0 -0
- eggpipe/interfaces/diagnostics.py +129 -0
- eggpipe/interfaces/formats.py +122 -0
- eggpipe/interfaces/generator.py +235 -0
- eggpipe/py.typed +0 -0
- eggpipe/templating.py +84 -0
- eggpipe/utils/__init__.py +0 -0
- eggpipe/utils/exceptions.py +65 -0
- eggpipe/utils/yaml.py +44 -0
- eggpipe/version.py +3 -0
- eggpipe/watch/__init__.py +0 -0
- eggpipe/watch/watcher.py +168 -0
- eggpipe-0.3.0.dist-info/METADATA +251 -0
- eggpipe-0.3.0.dist-info/RECORD +43 -0
- eggpipe-0.3.0.dist-info/WHEEL +4 -0
- eggpipe-0.3.0.dist-info/entry_points.txt +2 -0
- eggpipe-0.3.0.dist-info/licenses/LICENSE +21 -0
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()
|