roschema 0.1.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.
- roschema/__init__.py +3 -0
- roschema/cli.py +457 -0
- roschema/config.py +263 -0
- roschema/deep.py +114 -0
- roschema/differ.py +296 -0
- roschema/discovery/__init__.py +28 -0
- roschema/discovery/discover.py +74 -0
- roschema/discovery/model.py +55 -0
- roschema/discovery/resolve.py +209 -0
- roschema/formatter.py +48 -0
- roschema/freeze.py +109 -0
- roschema/ir.py +180 -0
- roschema/loader.py +32 -0
- roschema/mcap_fs.py +246 -0
- roschema/parser/__init__.py +36 -0
- roschema/parser/core.py +361 -0
- roschema/parser/errors.py +41 -0
- roschema/report/__init__.py +17 -0
- roschema/report/github.py +30 -0
- roschema/report/json_report.py +46 -0
- roschema/report/summary.py +64 -0
- roschema/report/text.py +106 -0
- roschema/rules/__init__.py +30 -0
- roschema/rules/breaking/__init__.py +44 -0
- roschema/rules/breaking/rs001_field_removed.py +27 -0
- roschema/rules/breaking/rs002_field_added.py +27 -0
- roschema/rules/breaking/rs003_field_type_changed.py +32 -0
- roschema/rules/breaking/rs004_fields_reordered.py +28 -0
- roschema/rules/breaking/rs005_field_renamed.py +28 -0
- roschema/rules/breaking/rs006_array_kind_changed.py +32 -0
- roschema/rules/breaking/rs007_bound_tightened.py +32 -0
- roschema/rules/breaking/rs008_bound_loosened.py +32 -0
- roschema/rules/breaking/rs020_constant_removed.py +24 -0
- roschema/rules/breaking/rs021_constant_value_changed.py +32 -0
- roschema/rules/breaking/rs022_constant_type_changed.py +29 -0
- roschema/rules/breaking/rs030_default_changed.py +38 -0
- roschema/rules/breaking/rs040_interface_removed.py +24 -0
- roschema/rules/breaking/rs060_message_version.py +39 -0
- roschema/rules/breaking/rs110_transitive.py +36 -0
- roschema/rules/gendocs.py +98 -0
- roschema/rules/lint/__init__.py +22 -0
- roschema/rules/lint/_common.py +34 -0
- roschema/rules/lint/rsl001_field_snake_case.py +36 -0
- roschema/rules/lint/rsl002_constant_upper_snake.py +33 -0
- roschema/rules/lint/rsl003_name_pascal_case.py +30 -0
- roschema/rules/lint/rsl004_missing_doc.py +35 -0
- roschema/rules/lint/rsl005_discouraged_types.py +42 -0
- roschema/rules/lint/rsl006_unbounded.py +43 -0
- roschema/rules/lint/rsl010_srv_shadow.py +43 -0
- roschema/rules/model.py +161 -0
- roschema/rules/registry.py +98 -0
- roschema/vfs.py +187 -0
- roschema-0.1.0.dist-info/METADATA +270 -0
- roschema-0.1.0.dist-info/RECORD +57 -0
- roschema-0.1.0.dist-info/WHEEL +4 -0
- roschema-0.1.0.dist-info/entry_points.txt +2 -0
- roschema-0.1.0.dist-info/licenses/LICENSE +201 -0
roschema/__init__.py
ADDED
roschema/cli.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""roschema command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import (
|
|
17
|
+
LEVEL_BUNDLES,
|
|
18
|
+
ConfigError,
|
|
19
|
+
finalize_findings,
|
|
20
|
+
finalize_lint,
|
|
21
|
+
load_config,
|
|
22
|
+
)
|
|
23
|
+
from .deep import transitive_findings
|
|
24
|
+
from .differ import diff_interfaces
|
|
25
|
+
from .discovery import (
|
|
26
|
+
Resolution,
|
|
27
|
+
apply_exclude,
|
|
28
|
+
resolve_ament,
|
|
29
|
+
resolve_bag,
|
|
30
|
+
resolve_baseline,
|
|
31
|
+
resolve_target,
|
|
32
|
+
)
|
|
33
|
+
from .formatter import format_text
|
|
34
|
+
from .freeze import build_lock
|
|
35
|
+
from .ir import InterfaceFile
|
|
36
|
+
from .loader import load_interfaces
|
|
37
|
+
from .parser import ParseError
|
|
38
|
+
from .report import build_github, build_json, render_lint, render_text
|
|
39
|
+
from .rules import Finding, all_lint_rules, evaluate, lint_interfaces
|
|
40
|
+
from .vfs import RealFS, VfsError, git_toplevel
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(
|
|
43
|
+
help="Breaking-change detection, linting and formatting for ROS 2 interfaces.",
|
|
44
|
+
no_args_is_help=True,
|
|
45
|
+
add_completion=False,
|
|
46
|
+
)
|
|
47
|
+
console = Console()
|
|
48
|
+
err_console = Console(stderr=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def _main() -> None:
|
|
53
|
+
"""roschema: keep ROS 2 interface changes honest."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def check(
|
|
58
|
+
target: Annotated[
|
|
59
|
+
str | None,
|
|
60
|
+
typer.Argument(help="Path, package name, or omit for the current workspace."),
|
|
61
|
+
] = None,
|
|
62
|
+
against: Annotated[
|
|
63
|
+
str,
|
|
64
|
+
typer.Option("--against", help="Baseline: git ref or directory path."),
|
|
65
|
+
] = ..., # type: ignore[assignment]
|
|
66
|
+
fmt: Annotated[
|
|
67
|
+
str, typer.Option("--format", help="Output format: text, json or github.")
|
|
68
|
+
] = "text",
|
|
69
|
+
level: Annotated[
|
|
70
|
+
str | None,
|
|
71
|
+
typer.Option("--level", help="Reporting level: wire, source, introspection, behavior."),
|
|
72
|
+
] = None,
|
|
73
|
+
config_path: Annotated[
|
|
74
|
+
Path | None,
|
|
75
|
+
typer.Option("--config", help="Explicit config file (roschema.toml or pyproject.toml)."),
|
|
76
|
+
] = None,
|
|
77
|
+
deep: Annotated[
|
|
78
|
+
bool,
|
|
79
|
+
typer.Option("--deep", help="Resolve nested types and flag transitive breakage (RS110)."),
|
|
80
|
+
] = False,
|
|
81
|
+
exit_zero: Annotated[
|
|
82
|
+
bool, typer.Option("--exit-zero", help="Exit 0 even when findings exist.")
|
|
83
|
+
] = False,
|
|
84
|
+
exclude: Annotated[
|
|
85
|
+
list[str] | None,
|
|
86
|
+
typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
|
|
87
|
+
] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Check interface changes against a baseline for breaking changes."""
|
|
90
|
+
if fmt not in ("text", "json", "github"):
|
|
91
|
+
err_console.print(f"[red]error:[/red] unknown format {fmt!r}")
|
|
92
|
+
raise typer.Exit(code=2)
|
|
93
|
+
if level is not None and level not in LEVEL_BUNDLES:
|
|
94
|
+
err_console.print(f"[red]error:[/red] unknown level {level!r}")
|
|
95
|
+
raise typer.Exit(code=2)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
findings, new_source, old_source, prefix = _compute(
|
|
99
|
+
target, lambda root: resolve_baseline(against, root), level, config_path, exclude, deep
|
|
100
|
+
)
|
|
101
|
+
except (VfsError, ParseError, ConfigError) as exc:
|
|
102
|
+
err_console.print(f"[red]error:[/red] {exc}")
|
|
103
|
+
raise typer.Exit(code=2) from exc
|
|
104
|
+
|
|
105
|
+
if old_source.startswith("git:") and Path(against).is_dir():
|
|
106
|
+
err_console.print(
|
|
107
|
+
f"[dim]note: {against!r} matched a git ref; "
|
|
108
|
+
f"use './{against}' to compare against the directory instead.[/dim]"
|
|
109
|
+
)
|
|
110
|
+
_emit(findings, fmt, new_source, old_source, prefix)
|
|
111
|
+
_exit_for(findings, exit_zero)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command(name="bag-check")
|
|
115
|
+
def bag_check(
|
|
116
|
+
bag: Annotated[str, typer.Argument(help="Recorded MCAP bag to check against.")],
|
|
117
|
+
targets: Annotated[
|
|
118
|
+
list[str] | None,
|
|
119
|
+
typer.Argument(
|
|
120
|
+
help="Paths/package names to compare against; default is the current "
|
|
121
|
+
"environment (ament index)."
|
|
122
|
+
),
|
|
123
|
+
] = None,
|
|
124
|
+
fmt: Annotated[
|
|
125
|
+
str, typer.Option("--format", help="Output format: text, json or github.")
|
|
126
|
+
] = "text",
|
|
127
|
+
exit_zero: Annotated[
|
|
128
|
+
bool, typer.Option("--exit-zero", help="Exit 0 even when findings exist.")
|
|
129
|
+
] = False,
|
|
130
|
+
exclude: Annotated[
|
|
131
|
+
list[str] | None,
|
|
132
|
+
typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
|
|
133
|
+
] = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Check a recorded MCAP bag against the current environment.
|
|
136
|
+
|
|
137
|
+
By default the bag's types are compared against the interfaces available in
|
|
138
|
+
the current ROS environment (the ament index). Supplying TARGETS (paths or
|
|
139
|
+
package names) overlays those definitions on top, so a package you are
|
|
140
|
+
actively editing can be checked from source instead of its installed copy.
|
|
141
|
+
"""
|
|
142
|
+
if fmt not in ("text", "json", "github"):
|
|
143
|
+
err_console.print(f"[red]error:[/red] unknown format {fmt!r}")
|
|
144
|
+
raise typer.Exit(code=2)
|
|
145
|
+
if not bag.endswith(".mcap"):
|
|
146
|
+
err_console.print(f"[red]error:[/red] expected a .mcap bag, got {bag!r}")
|
|
147
|
+
raise typer.Exit(code=2)
|
|
148
|
+
|
|
149
|
+
excludes = exclude or []
|
|
150
|
+
try:
|
|
151
|
+
old_res = resolve_bag(bag)
|
|
152
|
+
old_ifaces = load_interfaces(old_res.vfs, apply_exclude(old_res.packages, excludes))
|
|
153
|
+
new_ifaces, new_source = _resolve_current(old_ifaces, targets or [], excludes)
|
|
154
|
+
config = load_config(Path.cwd(), (), None)
|
|
155
|
+
except (VfsError, ParseError, ConfigError) as exc:
|
|
156
|
+
err_console.print(f"[red]error:[/red] {exc}")
|
|
157
|
+
raise typer.Exit(code=2) from exc
|
|
158
|
+
|
|
159
|
+
findings = finalize_findings(
|
|
160
|
+
evaluate(diff_interfaces(old_ifaces, new_ifaces)), config, None
|
|
161
|
+
)
|
|
162
|
+
_emit(findings, fmt, new_source, old_res.source)
|
|
163
|
+
_exit_for(findings, exit_zero)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _resolve_current(
|
|
167
|
+
bag_ifaces: dict[str, InterfaceFile], targets: list[str], excludes: list[str]
|
|
168
|
+
) -> tuple[dict[str, InterfaceFile], str]:
|
|
169
|
+
"""Build the current-side interfaces for a bag check.
|
|
170
|
+
|
|
171
|
+
Starts from the ament index (for the packages the bag references), then
|
|
172
|
+
overlays any explicit targets so they take precedence.
|
|
173
|
+
"""
|
|
174
|
+
current: dict[str, InterfaceFile] = {}
|
|
175
|
+
for package in sorted({key.split("/", 1)[0] for key in bag_ifaces}):
|
|
176
|
+
res = resolve_ament(package)
|
|
177
|
+
if res is not None:
|
|
178
|
+
current.update(load_interfaces(res.vfs, apply_exclude(res.packages, excludes)))
|
|
179
|
+
for target in targets:
|
|
180
|
+
res = resolve_target(target)
|
|
181
|
+
current.update(load_interfaces(res.vfs, apply_exclude(res.packages, excludes)))
|
|
182
|
+
source = "targets+environment" if targets else "environment (ament index)"
|
|
183
|
+
return current, source
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _compute(
|
|
187
|
+
target: str | None,
|
|
188
|
+
resolve_old: Callable[[Path], Resolution],
|
|
189
|
+
level: str | None,
|
|
190
|
+
config_path: Path | None,
|
|
191
|
+
exclude: list[str] | None,
|
|
192
|
+
deep: bool = False,
|
|
193
|
+
) -> tuple[list[Finding], str, str, str]:
|
|
194
|
+
"""Resolve both sides, diff and finalize; the shared check pipeline."""
|
|
195
|
+
new_res = resolve_target(target)
|
|
196
|
+
if not isinstance(new_res.vfs, RealFS):
|
|
197
|
+
raise VfsError("target must be a directory or workspace")
|
|
198
|
+
old_res = resolve_old(new_res.vfs.root)
|
|
199
|
+
config = load_config(new_res.vfs.root, new_res.packages, config_path)
|
|
200
|
+
excludes = exclude or []
|
|
201
|
+
new_ifaces = load_interfaces(new_res.vfs, apply_exclude(new_res.packages, excludes))
|
|
202
|
+
old_ifaces = load_interfaces(old_res.vfs, apply_exclude(old_res.packages, excludes))
|
|
203
|
+
if new_res.scope is not None:
|
|
204
|
+
# a glob target scopes both sides to the matched packages, so baseline-only
|
|
205
|
+
# packages outside the glob are not reported as removed
|
|
206
|
+
old_ifaces = {k: v for k, v in old_ifaces.items() if v.package in new_res.scope}
|
|
207
|
+
raw = evaluate(diff_interfaces(old_ifaces, new_ifaces))
|
|
208
|
+
if deep:
|
|
209
|
+
raw = raw + transitive_findings(new_ifaces, raw)
|
|
210
|
+
findings = finalize_findings(raw, config, level)
|
|
211
|
+
return findings, new_res.source, old_res.source, _display_prefix(new_res.vfs.root)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _display_prefix(target_dir: Path) -> str:
|
|
215
|
+
"""Repo-root-relative prefix for a real-filesystem target, or ``""``."""
|
|
216
|
+
top = git_toplevel(target_dir)
|
|
217
|
+
if top is None:
|
|
218
|
+
return ""
|
|
219
|
+
rel = os.path.relpath(target_dir, top)
|
|
220
|
+
return "" if rel == "." else rel
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _exit_for(findings: list[Finding], exit_zero: bool) -> None:
|
|
224
|
+
if exit_zero:
|
|
225
|
+
raise typer.Exit(code=0)
|
|
226
|
+
has_error = any(f.severity == "error" and not f.suppressed for f in findings)
|
|
227
|
+
raise typer.Exit(code=1 if has_error else 0)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _emit(
|
|
231
|
+
findings: list[Finding],
|
|
232
|
+
fmt: str,
|
|
233
|
+
new_source: str,
|
|
234
|
+
old_source: str,
|
|
235
|
+
display_prefix: str = "",
|
|
236
|
+
) -> None:
|
|
237
|
+
if fmt == "json":
|
|
238
|
+
typer.echo(json.dumps(build_json(findings), indent=2))
|
|
239
|
+
elif fmt == "github":
|
|
240
|
+
for line in build_github(findings, display_prefix):
|
|
241
|
+
typer.echo(line)
|
|
242
|
+
else:
|
|
243
|
+
render_text(findings, console, new_source, old_source)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@app.command()
|
|
247
|
+
def lint(
|
|
248
|
+
target: Annotated[
|
|
249
|
+
str | None,
|
|
250
|
+
typer.Argument(help="Path, package name, or omit for the current workspace."),
|
|
251
|
+
] = None,
|
|
252
|
+
fmt: Annotated[
|
|
253
|
+
str, typer.Option("--format", help="Output format: text, json or github.")
|
|
254
|
+
] = "text",
|
|
255
|
+
config_path: Annotated[
|
|
256
|
+
Path | None, typer.Option("--config", help="Explicit config file.")
|
|
257
|
+
] = None,
|
|
258
|
+
rules: Annotated[
|
|
259
|
+
list[str] | None,
|
|
260
|
+
typer.Option(
|
|
261
|
+
"--rules",
|
|
262
|
+
help="Rule severity override(s), e.g. RSL004=warn or RSL001=off "
|
|
263
|
+
"(bare RSL### means =warn). Repeatable; beats config.",
|
|
264
|
+
),
|
|
265
|
+
] = None,
|
|
266
|
+
exit_zero: Annotated[
|
|
267
|
+
bool, typer.Option("--exit-zero", help="Exit 0 even when findings exist.")
|
|
268
|
+
] = False,
|
|
269
|
+
exclude: Annotated[
|
|
270
|
+
list[str] | None,
|
|
271
|
+
typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
|
|
272
|
+
] = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Lint interface definitions in a single tree (no baseline)."""
|
|
275
|
+
if fmt not in ("text", "json", "github"):
|
|
276
|
+
err_console.print(f"[red]error:[/red] unknown format {fmt!r}")
|
|
277
|
+
raise typer.Exit(code=2)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
overrides = _parse_rule_overrides(rules or [])
|
|
281
|
+
res = resolve_target(target)
|
|
282
|
+
if not isinstance(res.vfs, RealFS):
|
|
283
|
+
raise VfsError("lint target must be a directory or workspace")
|
|
284
|
+
config = load_config(res.vfs.root, res.packages, config_path)
|
|
285
|
+
config = replace(config, lint_rules={**config.lint_rules, **overrides})
|
|
286
|
+
interfaces = load_interfaces(res.vfs, apply_exclude(res.packages, exclude or []))
|
|
287
|
+
except (VfsError, ParseError, ConfigError) as exc:
|
|
288
|
+
err_console.print(f"[red]error:[/red] {exc}")
|
|
289
|
+
raise typer.Exit(code=2) from exc
|
|
290
|
+
|
|
291
|
+
findings = finalize_lint(lint_interfaces(interfaces), config)
|
|
292
|
+
if fmt == "json":
|
|
293
|
+
typer.echo(json.dumps(build_json(findings), indent=2))
|
|
294
|
+
elif fmt == "github":
|
|
295
|
+
for line in build_github(findings, _display_prefix(res.vfs.root)):
|
|
296
|
+
typer.echo(line)
|
|
297
|
+
else:
|
|
298
|
+
render_lint(findings, console, res.source)
|
|
299
|
+
|
|
300
|
+
if exit_zero:
|
|
301
|
+
raise typer.Exit(code=0)
|
|
302
|
+
has_error = any(f.severity == "error" and not f.suppressed for f in findings)
|
|
303
|
+
raise typer.Exit(code=1 if has_error else 0)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _parse_rule_overrides(items: list[str]) -> dict[str, str]:
|
|
307
|
+
"""Parse ``--rules RSL004=warn`` items (bare ``RSL###`` means ``=warn``)."""
|
|
308
|
+
valid = {rule.id for rule in all_lint_rules()}
|
|
309
|
+
out: dict[str, str] = {}
|
|
310
|
+
for item in items:
|
|
311
|
+
rule_id, _, severity = item.partition("=")
|
|
312
|
+
rule_id = rule_id.strip()
|
|
313
|
+
severity = severity.strip() or "warn"
|
|
314
|
+
if rule_id not in valid:
|
|
315
|
+
raise ConfigError(f"--rules: unknown lint rule {rule_id!r}")
|
|
316
|
+
if severity not in ("off", "warn", "error"):
|
|
317
|
+
raise ConfigError(f"--rules: invalid severity {severity!r} for {rule_id}")
|
|
318
|
+
out[rule_id] = severity
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.command()
|
|
323
|
+
def fmt(
|
|
324
|
+
target: Annotated[
|
|
325
|
+
str | None,
|
|
326
|
+
typer.Argument(help="Path, package name, or omit for the current workspace."),
|
|
327
|
+
] = None,
|
|
328
|
+
check_only: Annotated[
|
|
329
|
+
bool, typer.Option("--check", help="Report files that need formatting; do not write.")
|
|
330
|
+
] = False,
|
|
331
|
+
exclude: Annotated[
|
|
332
|
+
list[str] | None,
|
|
333
|
+
typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
|
|
334
|
+
] = None,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Canonically format interface files (idempotent, comment-preserving)."""
|
|
337
|
+
try:
|
|
338
|
+
res = resolve_target(target)
|
|
339
|
+
if not isinstance(res.vfs, RealFS):
|
|
340
|
+
raise VfsError("fmt target must be a directory or workspace")
|
|
341
|
+
packages = apply_exclude(res.packages, exclude or [])
|
|
342
|
+
changed: list[str] = []
|
|
343
|
+
for pkg in packages:
|
|
344
|
+
for ref in pkg.interfaces:
|
|
345
|
+
original = res.vfs.read_text(ref.path)
|
|
346
|
+
formatted = format_text(original, ref.path)
|
|
347
|
+
if formatted != original:
|
|
348
|
+
changed.append(ref.path)
|
|
349
|
+
if not check_only:
|
|
350
|
+
(res.vfs.root / ref.path).write_text(formatted)
|
|
351
|
+
except (VfsError, ParseError) as exc:
|
|
352
|
+
err_console.print(f"[red]error:[/red] {exc}")
|
|
353
|
+
raise typer.Exit(code=2) from exc
|
|
354
|
+
|
|
355
|
+
verb = "would reformat" if check_only else "reformatted"
|
|
356
|
+
for path in changed:
|
|
357
|
+
console.print(f"{verb} {path}")
|
|
358
|
+
if check_only:
|
|
359
|
+
if changed:
|
|
360
|
+
console.print(f"[yellow]{len(changed)} file(s) need formatting[/yellow]")
|
|
361
|
+
raise typer.Exit(code=1)
|
|
362
|
+
console.print("[green]all files formatted[/green]")
|
|
363
|
+
raise typer.Exit(code=0)
|
|
364
|
+
console.print(f"{len(changed)} file(s) reformatted")
|
|
365
|
+
raise typer.Exit(code=0)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@app.command()
|
|
369
|
+
def freeze(
|
|
370
|
+
target: Annotated[
|
|
371
|
+
str | None,
|
|
372
|
+
typer.Argument(help="Path, package name, or omit for the current workspace."),
|
|
373
|
+
] = None,
|
|
374
|
+
output: Annotated[
|
|
375
|
+
Path, typer.Option("-o", "--output", help="Lockfile path to write.")
|
|
376
|
+
] = Path("roschema.lock"),
|
|
377
|
+
exclude: Annotated[
|
|
378
|
+
list[str] | None,
|
|
379
|
+
typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
|
|
380
|
+
] = None,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Write a roschema.lock baseline (canonical text + sha256 per interface)."""
|
|
383
|
+
try:
|
|
384
|
+
res = resolve_target(target)
|
|
385
|
+
interfaces = load_interfaces(res.vfs, apply_exclude(res.packages, exclude or []))
|
|
386
|
+
except (VfsError, ParseError) as exc:
|
|
387
|
+
err_console.print(f"[red]error:[/red] {exc}")
|
|
388
|
+
raise typer.Exit(code=2) from exc
|
|
389
|
+
|
|
390
|
+
output.write_text(build_lock(interfaces, __version__))
|
|
391
|
+
console.print(f"wrote {len(interfaces)} interface(s) to {output}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@app.command()
|
|
395
|
+
def ls(
|
|
396
|
+
target: Annotated[
|
|
397
|
+
str | None,
|
|
398
|
+
typer.Argument(help="Path, package name, or omit for the current workspace."),
|
|
399
|
+
] = None,
|
|
400
|
+
json_out: Annotated[bool, typer.Option("--json", help="Emit JSON.")] = False,
|
|
401
|
+
exclude: Annotated[
|
|
402
|
+
list[str] | None,
|
|
403
|
+
typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
|
|
404
|
+
] = None,
|
|
405
|
+
) -> None:
|
|
406
|
+
"""List discovered interface packages and the source they came from."""
|
|
407
|
+
try:
|
|
408
|
+
resolution = resolve_target(target)
|
|
409
|
+
except VfsError as exc:
|
|
410
|
+
err_console.print(f"[red]error:[/red] {exc}")
|
|
411
|
+
raise typer.Exit(code=2) from exc
|
|
412
|
+
|
|
413
|
+
packages = apply_exclude(resolution.packages, exclude or [])
|
|
414
|
+
resolution = Resolution(vfs=resolution.vfs, packages=packages, source=resolution.source)
|
|
415
|
+
|
|
416
|
+
if json_out:
|
|
417
|
+
typer.echo(json.dumps(_to_json(resolution), indent=2))
|
|
418
|
+
return
|
|
419
|
+
_print_text(resolution)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _to_json(resolution: Resolution) -> dict[str, object]:
|
|
423
|
+
return {
|
|
424
|
+
"source": resolution.source,
|
|
425
|
+
"packages": [
|
|
426
|
+
{
|
|
427
|
+
"name": pkg.name,
|
|
428
|
+
"path": pkg.path,
|
|
429
|
+
"interfaces": [
|
|
430
|
+
{"kind": ref.kind, "name": ref.name, "path": ref.path}
|
|
431
|
+
for ref in pkg.interfaces
|
|
432
|
+
],
|
|
433
|
+
}
|
|
434
|
+
for pkg in resolution.packages
|
|
435
|
+
],
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _print_text(resolution: Resolution) -> None:
|
|
440
|
+
console.print(f"[bold]source:[/bold] {resolution.source}")
|
|
441
|
+
if not resolution.packages:
|
|
442
|
+
console.print("[yellow]no interface packages found[/yellow]")
|
|
443
|
+
return
|
|
444
|
+
total = 0
|
|
445
|
+
for pkg in resolution.packages:
|
|
446
|
+
console.print(f"\n[bold cyan]{pkg.name}[/bold cyan] [dim]{pkg.path}[/dim]")
|
|
447
|
+
for ref in pkg.interfaces:
|
|
448
|
+
console.print(f" {ref.kind:<7} {ref.name} [dim]{ref.path}[/dim]")
|
|
449
|
+
total += 1
|
|
450
|
+
plural = "interface" if total == 1 else "interfaces"
|
|
451
|
+
console.print(
|
|
452
|
+
f"\n[dim]{len(resolution.packages)} package(s), {total} {plural}[/dim]"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__": # pragma: no cover
|
|
457
|
+
app()
|