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.
Files changed (57) hide show
  1. roschema/__init__.py +3 -0
  2. roschema/cli.py +457 -0
  3. roschema/config.py +263 -0
  4. roschema/deep.py +114 -0
  5. roschema/differ.py +296 -0
  6. roschema/discovery/__init__.py +28 -0
  7. roschema/discovery/discover.py +74 -0
  8. roschema/discovery/model.py +55 -0
  9. roschema/discovery/resolve.py +209 -0
  10. roschema/formatter.py +48 -0
  11. roschema/freeze.py +109 -0
  12. roschema/ir.py +180 -0
  13. roschema/loader.py +32 -0
  14. roschema/mcap_fs.py +246 -0
  15. roschema/parser/__init__.py +36 -0
  16. roschema/parser/core.py +361 -0
  17. roschema/parser/errors.py +41 -0
  18. roschema/report/__init__.py +17 -0
  19. roschema/report/github.py +30 -0
  20. roschema/report/json_report.py +46 -0
  21. roschema/report/summary.py +64 -0
  22. roschema/report/text.py +106 -0
  23. roschema/rules/__init__.py +30 -0
  24. roschema/rules/breaking/__init__.py +44 -0
  25. roschema/rules/breaking/rs001_field_removed.py +27 -0
  26. roschema/rules/breaking/rs002_field_added.py +27 -0
  27. roschema/rules/breaking/rs003_field_type_changed.py +32 -0
  28. roschema/rules/breaking/rs004_fields_reordered.py +28 -0
  29. roschema/rules/breaking/rs005_field_renamed.py +28 -0
  30. roschema/rules/breaking/rs006_array_kind_changed.py +32 -0
  31. roschema/rules/breaking/rs007_bound_tightened.py +32 -0
  32. roschema/rules/breaking/rs008_bound_loosened.py +32 -0
  33. roschema/rules/breaking/rs020_constant_removed.py +24 -0
  34. roschema/rules/breaking/rs021_constant_value_changed.py +32 -0
  35. roschema/rules/breaking/rs022_constant_type_changed.py +29 -0
  36. roschema/rules/breaking/rs030_default_changed.py +38 -0
  37. roschema/rules/breaking/rs040_interface_removed.py +24 -0
  38. roschema/rules/breaking/rs060_message_version.py +39 -0
  39. roschema/rules/breaking/rs110_transitive.py +36 -0
  40. roschema/rules/gendocs.py +98 -0
  41. roschema/rules/lint/__init__.py +22 -0
  42. roschema/rules/lint/_common.py +34 -0
  43. roschema/rules/lint/rsl001_field_snake_case.py +36 -0
  44. roschema/rules/lint/rsl002_constant_upper_snake.py +33 -0
  45. roschema/rules/lint/rsl003_name_pascal_case.py +30 -0
  46. roschema/rules/lint/rsl004_missing_doc.py +35 -0
  47. roschema/rules/lint/rsl005_discouraged_types.py +42 -0
  48. roschema/rules/lint/rsl006_unbounded.py +43 -0
  49. roschema/rules/lint/rsl010_srv_shadow.py +43 -0
  50. roschema/rules/model.py +161 -0
  51. roschema/rules/registry.py +98 -0
  52. roschema/vfs.py +187 -0
  53. roschema-0.1.0.dist-info/METADATA +270 -0
  54. roschema-0.1.0.dist-info/RECORD +57 -0
  55. roschema-0.1.0.dist-info/WHEEL +4 -0
  56. roschema-0.1.0.dist-info/entry_points.txt +2 -0
  57. roschema-0.1.0.dist-info/licenses/LICENSE +201 -0
roschema/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """roschema: breaking-change detection, linting and formatting for ROS 2 interfaces."""
2
+
3
+ __version__ = "0.1.0"
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()