mapcan 0.21.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 (85) hide show
  1. mapcan/__init__.py +9 -0
  2. mapcan/bits.py +85 -0
  3. mapcan/canopen.py +64 -0
  4. mapcan/cli.py +598 -0
  5. mapcan/codegen/__init__.py +0 -0
  6. mapcan/codegen/c.py +451 -0
  7. mapcan/codegen/common.py +95 -0
  8. mapcan/codegen/py.py +277 -0
  9. mapcan/diagnostics.py +65 -0
  10. mapcan/diff.py +383 -0
  11. mapcan/docmodel.py +556 -0
  12. mapcan/examples/canopen-node.yaml +113 -0
  13. mapcan/examples/j1939.yaml +193 -0
  14. mapcan/examples/minimal.yaml +81 -0
  15. mapcan/examples/tractor/powertrain.yaml +103 -0
  16. mapcan/examples/tractor/tractor.yaml +52 -0
  17. mapcan/examples/vehicle-can.yaml +163 -0
  18. mapcan/export/__init__.py +0 -0
  19. mapcan/export/dbc.py +195 -0
  20. mapcan/export/eds.py +215 -0
  21. mapcan/export/pdf.py +144 -0
  22. mapcan/export/text.py +70 -0
  23. mapcan/export/xlsx.py +416 -0
  24. mapcan/extras.py +38 -0
  25. mapcan/fmt.py +316 -0
  26. mapcan/fonts/OFL-PTSerif.txt +93 -0
  27. mapcan/fonts/OFL.txt +93 -0
  28. mapcan/fonts/PT_Sans-Web-Bold.ttf +0 -0
  29. mapcan/fonts/PT_Sans-Web-BoldItalic.ttf +0 -0
  30. mapcan/fonts/PT_Sans-Web-Italic.ttf +0 -0
  31. mapcan/fonts/PT_Sans-Web-Regular.ttf +0 -0
  32. mapcan/fonts/PT_Serif-Web-Bold.ttf +0 -0
  33. mapcan/fonts/PT_Serif-Web-BoldItalic.ttf +0 -0
  34. mapcan/fonts/PT_Serif-Web-Italic.ttf +0 -0
  35. mapcan/fonts/PT_Serif-Web-Regular.ttf +0 -0
  36. mapcan/i18n.py +34 -0
  37. mapcan/importers/__init__.py +1 -0
  38. mapcan/importers/dbc.py +257 -0
  39. mapcan/launcher.py +332 -0
  40. mapcan/layout.py +46 -0
  41. mapcan/loader.py +583 -0
  42. mapcan/locale/__init__.py +1 -0
  43. mapcan/locale/ru.py +952 -0
  44. mapcan/lsp/__init__.py +1 -0
  45. mapcan/lsp/analysis.py +865 -0
  46. mapcan/lsp/edits.py +1213 -0
  47. mapcan/lsp/exports.py +57 -0
  48. mapcan/lsp/server.py +588 -0
  49. mapcan/model.py +802 -0
  50. mapcan/protocols.py +47 -0
  51. mapcan/resolve.py +118 -0
  52. mapcan/schema.py +37 -0
  53. mapcan/templates/document.html.j2 +207 -0
  54. mapcan/templates/document.md.j2 +151 -0
  55. mapcan/templates/pdf/gost-19.typ +188 -0
  56. mapcan/templates/pdf/gost-2.105.typ +242 -0
  57. mapcan/templates/pdf/gost-7.32.typ +196 -0
  58. mapcan/templates/pdf/lib.typ +332 -0
  59. mapcan/templates/pdf/mapcan.typ +117 -0
  60. mapcan/templates/xlsx_style.yaml +22 -0
  61. mapcan/units.py +61 -0
  62. mapcan/updates.py +85 -0
  63. mapcan/upgrade.py +159 -0
  64. mapcan/userconfig.py +98 -0
  65. mapcan/validate.py +853 -0
  66. mapcan/web/__init__.py +0 -0
  67. mapcan/web/app.py +1292 -0
  68. mapcan/web/examples.py +88 -0
  69. mapcan/web/newmap.py +160 -0
  70. mapcan/web/session.py +438 -0
  71. mapcan/web/static/OFL-Sora.txt +93 -0
  72. mapcan/web/static/assets/index-DRMUrO54.js +31 -0
  73. mapcan/web/static/assets/index-S8VlG2gr.css +1 -0
  74. mapcan/web/static/favicon.svg +7 -0
  75. mapcan/web/static/index.html +14 -0
  76. mapcan/web/yamlops.py +768 -0
  77. mapcan-0.21.0.dist-info/METADATA +492 -0
  78. mapcan-0.21.0.dist-info/RECORD +85 -0
  79. mapcan-0.21.0.dist-info/WHEEL +4 -0
  80. mapcan-0.21.0.dist-info/entry_points.txt +5 -0
  81. mapcan-0.21.0.dist-info/licenses/LICENSE +176 -0
  82. mapcan-0.21.0.dist-info/licenses/NOTICE +15 -0
  83. mapcan-0.21.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +89 -0
  84. mapcan-0.21.0.dist-info/licenses/src/mapcan/fonts/OFL-PTSerif.txt +93 -0
  85. mapcan-0.21.0.dist-info/licenses/src/mapcan/fonts/OFL.txt +93 -0
mapcan/cli.py ADDED
@@ -0,0 +1,598 @@
1
+ """Command line of mapcan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sys
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import typer
13
+
14
+ from . import __version__
15
+ from .diagnostics import Report
16
+ from .i18n import _
17
+ from .loader import LoadResult
18
+ from .validate import RULES, validate_file
19
+
20
+ app = typer.Typer(
21
+ help=_("mapcan — CAN signal map in YAML → validation, documentation, code."),
22
+ no_args_is_help=True,
23
+ add_completion=False,
24
+ rich_markup_mode=None,
25
+ pretty_exceptions_enable=False,
26
+ )
27
+ export_app = typer.Typer(help=_("Export documents and files for third-party tools."), no_args_is_help=True)
28
+ gen_app = typer.Typer(help=_("Code generation."), no_args_is_help=True)
29
+ import_app = typer.Typer(help=_("Import a map from other tools' formats."), no_args_is_help=True)
30
+ app.add_typer(export_app, name="export")
31
+ app.add_typer(gen_app, name="gen")
32
+ app.add_typer(import_app, name="import")
33
+
34
+ COLORS = {"error": typer.colors.RED, "warning": typer.colors.YELLOW, "info": typer.colors.BLUE}
35
+
36
+ MapArg = typer.Argument(..., exists=True, dir_okay=False, readable=True, help=_("Map YAML file."))
37
+ LangOpt = typer.Option(None, "--lang", help=_("Document language (ISO 639-1 code: ru, en, de…); defaults to the map's "
38
+ "language."))
39
+ ForceOpt = typer.Option(False, "--force", help=_("Generate even if validation reports errors."))
40
+
41
+
42
+ class OutFormat(str, Enum):
43
+ text = "text"
44
+ json = "json"
45
+
46
+
47
+ def _print_report(report: Report, show_info: bool = False, stream=None) -> None:
48
+ for d in report.sorted():
49
+ if d.level == "info" and not show_info:
50
+ continue
51
+ typer.secho(d.format(), fg=COLORS[d.level], err=stream is None)
52
+
53
+
54
+ def _summary(report: Report) -> str:
55
+ return _("errors: {errors}, warnings: {warnings}, notes: {infos}",
56
+ errors=report.errors, warnings=report.warnings, infos=report.count("info"))
57
+
58
+
59
+ def _load_checked(path: Path, force: bool) -> LoadResult:
60
+ res, report = validate_file(path)
61
+ _print_report(report)
62
+ if res.map is None or (report.errors and not force):
63
+ typer.secho(_("Generation aborted: {summary}. Fix the errors (mapcan validate) or use --force.",
64
+ summary=_summary(report)), fg=typer.colors.RED, err=True)
65
+ raise typer.Exit(1)
66
+ return res
67
+
68
+
69
+ def _done(paths) -> None:
70
+ for p in paths if isinstance(paths, (list, tuple)) else [paths]:
71
+ typer.echo(f"→ {p}")
72
+
73
+
74
+ def _warn(message: str) -> None:
75
+ typer.secho(message, fg=typer.colors.YELLOW, err=True)
76
+
77
+
78
+ def _default_out(path: Path, ext: str, out: Optional[Path]) -> Path:
79
+ return out if out else Path(path.stem + ext)
80
+
81
+
82
+ @app.callback(invoke_without_command=True)
83
+ def _main(version: bool = typer.Option(False, "--version", help=_("Show the version."))) -> None:
84
+ if version:
85
+ typer.echo(f"mapcan {__version__}")
86
+ raise typer.Exit()
87
+
88
+
89
+ # --------------------------------------------------------------------------- #
90
+
91
+
92
+ @app.command(help=_("Validate the map. Exit code 1 on errors (and on warnings with --strict)."))
93
+ def validate(
94
+ path: Path = MapArg,
95
+ strict: bool = typer.Option(False, "--strict", help=_("Treat warnings as errors.")),
96
+ fmt: OutFormat = typer.Option(OutFormat.text, "--format", help=_("Output format.")),
97
+ show_info: bool = typer.Option(False, "--info", help=_("Show info-level notes.")),
98
+ disable: list[str] = typer.Option([], "--disable", help=_("Disable a rule (may be repeated).")),
99
+ ) -> None:
100
+ res, report = validate_file(path, disable)
101
+ if fmt == OutFormat.json:
102
+ typer.echo(report.to_json())
103
+ else:
104
+ _print_report(report, show_info, stream=sys.stdout)
105
+ ok = report.errors == 0 and not (strict and report.warnings)
106
+ typer.secho(("OK: " if ok else "FAIL: ") + _summary(report),
107
+ fg=typer.colors.GREEN if ok else typer.colors.RED)
108
+ if report.errors or (strict and report.warnings):
109
+ raise typer.Exit(1)
110
+
111
+
112
+ @app.command(help=_(
113
+ "Browser-based editor (for servers and development; on a desktop, `mapcan app` is more convenient)."))
114
+ def serve(
115
+ path: Optional[Path] = typer.Argument(
116
+ None, exists=True, dir_okay=True, readable=True,
117
+ help=_("Map to open, or a working folder. Without an argument, the start screen is shown.")),
118
+ host: str = typer.Option("127.0.0.1", "--host", help=_("Address to listen on.")),
119
+ port: Optional[int] = typer.Option(None, "--port", help=_("Port; defaults to 8765 or any free port.")),
120
+ readonly: bool = typer.Option(False, "--readonly", help=_("View only, no saving.")),
121
+ no_browser: bool = typer.Option(False, "--no-browser", help=_("Do not open a browser.")),
122
+ token: Optional[str] = typer.Option(
123
+ None, "--token", envvar="MAPCAN_TOKEN",
124
+ help=_("Access token on a network address; a random one is generated if not given.")),
125
+ no_auth: bool = typer.Option(
126
+ False, "--no-auth", help=_("No token on a network address (behind a proxy that authenticates users).")),
127
+ ) -> None:
128
+ import secrets
129
+ import webbrowser
130
+
131
+ _need("web")
132
+ from .launcher import DEFAULT_PORT, LOCAL_HOSTS, ServerThread, browser_url, pick_port
133
+ from .web.app import create_app
134
+ from .web.session import EditorSession
135
+
136
+ local = host in LOCAL_HOSTS
137
+ auth_token = None
138
+ if not local and no_auth:
139
+ typer.secho(_("Warning: the editor is reachable over the network ({host}) without authentication. Anyone who "
140
+ "opens the address can open and change files.", host=host),
141
+ fg=typer.colors.YELLOW, err=True)
142
+ elif not local:
143
+ auth_token = token or secrets.token_urlsafe(24)
144
+ session, cwd = None, Path.cwd()
145
+ if path is not None and path.is_dir():
146
+ cwd = path
147
+ elif path is not None:
148
+ session = EditorSession(path, readonly=readonly)
149
+ if port is None:
150
+ port = pick_port(host, DEFAULT_PORT) if local else DEFAULT_PORT
151
+ url = browser_url(host, port)
152
+ if auth_token:
153
+ from urllib.parse import quote
154
+
155
+ url += f"?token={quote(auth_token)}"
156
+ server = ServerThread(create_app(session, cwd=cwd.resolve(), readonly=readonly, check_updates=local,
157
+ allowed_hosts=LOCAL_HOSTS if local else None, auth_token=auth_token),
158
+ host, port)
159
+ try:
160
+ server.start()
161
+ except RuntimeError as e:
162
+ typer.secho(_("Error: {error}", error=e), fg=typer.colors.RED, err=True)
163
+ raise typer.Exit(1) from e
164
+ typer.echo(_("mapcan {version}: {path} → {url} (Ctrl+C to stop)",
165
+ version=__version__, path=path or cwd, url=url))
166
+ if not no_browser:
167
+ webbrowser.open(url)
168
+ server.wait()
169
+
170
+
171
+ @app.command("app", help=_("Editor in its own window (pywebview); running it again opens the file in the existing "
172
+ "window."))
173
+ def app_cmd(
174
+ path: Optional[Path] = typer.Argument(
175
+ None, exists=True, dir_okay=True, readable=True,
176
+ help=_("Map to open, or a working folder. Without an argument, the start screen is shown.")),
177
+ browser: bool = typer.Option(False, "--browser", help=_("Open in a browser instead of the app window.")),
178
+ readonly: bool = typer.Option(False, "--readonly", help=_("View only, no saving.")),
179
+ ) -> None:
180
+ _need("web")
181
+ from .launcher import run
182
+
183
+ raise typer.Exit(run(path, window=not browser, readonly=readonly))
184
+
185
+
186
+ @app.command(help=_("Language server (LSP) over stdio: validation and hints in VS Code and other editors."))
187
+ def lsp() -> None:
188
+ _need("lsp")
189
+ from .lsp.server import main as lsp_main
190
+
191
+ lsp_main()
192
+
193
+
194
+ def _need(*extras: str) -> None:
195
+ """Stop with a clear message when an optional part of mapcan is not installed (see extras.py)."""
196
+ from .extras import MissingExtra, require
197
+
198
+ try:
199
+ require(*extras)
200
+ except MissingExtra as e:
201
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
202
+ raise typer.Exit(1) from e
203
+
204
+
205
+ class DiffFormat(str, Enum):
206
+ text = "text"
207
+ md = "md"
208
+ json = "json"
209
+
210
+
211
+ @app.command(help=_("Compare two versions of a map (frames, signals, values) and print a changelog."))
212
+ def diff(
213
+ old: Path = typer.Argument(..., exists=True, dir_okay=False, help=_("Old version of the map.")),
214
+ new: Path = typer.Argument(..., exists=True, dir_okay=False, help=_("New version of the map.")),
215
+ fmt: DiffFormat = typer.Option(DiffFormat.text, "--format", help="text | md | json"),
216
+ lang: Optional[str] = LangOpt,
217
+ ) -> None:
218
+ from .diff import diff_maps, render_diff
219
+ from .loader import load
220
+
221
+ maps = []
222
+ for p in (old, new):
223
+ res = load(p)
224
+ if res.map is None:
225
+ _print_report(res.report)
226
+ typer.secho(_("{path}: the map could not be loaded", path=p), fg=typer.colors.RED, err=True)
227
+ raise typer.Exit(1)
228
+ maps.append(res.map)
229
+ d = diff_maps(maps[0], maps[1], lang)
230
+ typer.echo(render_diff(d, fmt.value, lang or maps[1].lang))
231
+
232
+
233
+ @app.command(help=_("Rewrite the former names of fields (deprecated-key) to the current ones, keeping comments and "
234
+ "layout. Included files are rewritten too."))
235
+ def migrate(
236
+ paths: list[Path] = typer.Argument(..., exists=True, dir_okay=False, help=_("Map files.")),
237
+ check: bool = typer.Option(False, "--check", help=_("Do not write; exit code 1 if a file has former names.")),
238
+ ) -> None:
239
+ from .web.yamlops import migrate_text
240
+
241
+ outdated = []
242
+ for f in _with_included(paths):
243
+ raw, text = _read_map(f)
244
+ new = migrate_text(text)
245
+ if new == text:
246
+ continue
247
+ outdated.append(f)
248
+ if check:
249
+ typer.echo(_("{file}: former field names, run mapcan migrate", file=f))
250
+ else:
251
+ _write_map(f, raw, new)
252
+ if not outdated:
253
+ typer.echo(_("No former field names found."))
254
+ elif check:
255
+ raise typer.Exit(1)
256
+
257
+
258
+ @app.command(help=_("Write the keys of every node in one order (that of docs/format.md), keeping comments, "
259
+ "layout and number notation. Included files are formatted too."))
260
+ def fmt(
261
+ paths: list[Path] = typer.Argument(..., exists=True, dir_okay=False, help=_("Map files.")),
262
+ check: bool = typer.Option(False, "--check", help=_("Do not write; exit code 1 if a file is not formatted.")),
263
+ ) -> None:
264
+ from .fmt import FmtError, format_text
265
+
266
+ unformatted, failed = [], False
267
+ for f in _with_included(paths):
268
+ raw, text = _read_map(f)
269
+ try:
270
+ new = format_text(text)
271
+ except FmtError as e:
272
+ typer.secho(f"{f}: {e}", fg=typer.colors.RED, err=True)
273
+ failed = True
274
+ continue
275
+ if new == text:
276
+ continue
277
+ unformatted.append(f)
278
+ if check:
279
+ typer.echo(_("{file}: not formatted, run mapcan fmt", file=f))
280
+ else:
281
+ _write_map(f, raw, new)
282
+ if failed or (check and unformatted):
283
+ raise typer.Exit(1)
284
+ if not unformatted:
285
+ typer.echo(_("All files are formatted."))
286
+
287
+
288
+ def _with_included(paths: list[Path]) -> list[Path]:
289
+ """The maps and the files they include."""
290
+ from ruamel.yaml import YAML
291
+
292
+ files: list[Path] = []
293
+ for p in paths:
294
+ if p not in files:
295
+ files.append(p)
296
+ try:
297
+ data = YAML(typ="safe").load(p.read_text(encoding="utf-8"))
298
+ except Exception: # noqa: BLE001 — a broken file is reported by validate
299
+ data = None
300
+ for inc in data.get("include") or [] if isinstance(data, dict) else []:
301
+ q = p.parent / str(inc)
302
+ if q.is_file() and q not in files:
303
+ files.append(q)
304
+ return files
305
+
306
+
307
+ def _read_map(f: Path) -> tuple[str, str]:
308
+ """The file's text as it is and with \\n line endings."""
309
+ raw = f.read_bytes().decode("utf-8")
310
+ return raw, raw.replace("\r\n", "\n")
311
+
312
+
313
+ def _write_map(f: Path, raw: str, new: str) -> None:
314
+ # The file keeps its line endings
315
+ f.write_bytes((new.replace("\n", "\r\n") if "\r\n" in raw else new).encode("utf-8"))
316
+ _done(f)
317
+
318
+
319
+ @app.command(help=_("List validation rules."))
320
+ def rules() -> None:
321
+ for code, (level, desc) in RULES.items():
322
+ typer.echo(f"{code:24} {level:8} {_(desc)}")
323
+
324
+
325
+ @app.command(help=_("Print the format's JSON Schema (for IDE hints)."))
326
+ def schema(
327
+ out: Optional[Path] = typer.Option(None, "-o", "--output", help=_("JSON Schema file.")),
328
+ part: bool = typer.Option(False, "--part", help=_("Schema of a file included by a map (part_of).")),
329
+ ) -> None:
330
+ from .schema import json_schema
331
+
332
+ text = json.dumps(json_schema(part), ensure_ascii=False, indent=2)
333
+ if out:
334
+ out.parent.mkdir(parents=True, exist_ok=True)
335
+ out.write_text(text + "\n", encoding="utf-8")
336
+ _done(out)
337
+ else:
338
+ typer.echo(text)
339
+
340
+
341
+ class TemplateKind(str, Enum):
342
+ pdf = "pdf"
343
+ md = "md"
344
+ html = "html"
345
+ xlsx_style = "xlsx-style"
346
+
347
+
348
+ @app.command(help=_("List PDF templates or copy a built-in template to customize the layout."))
349
+ def template(
350
+ kind: Optional[TemplateKind] = typer.Argument(None, help="pdf | md | html | xlsx-style"),
351
+ out: Optional[Path] = typer.Option(None, "-o", "--output", help=_("Where to save the template copy.")),
352
+ name: str = typer.Option("mapcan", "--name", help=_("Built-in PDF template (see --list).")),
353
+ list_: bool = typer.Option(False, "--list", help=_("Show built-in PDF templates.")),
354
+ ) -> None:
355
+ from .export.pdf import PDF_TEMPLATE_ALIASES, PDF_TEMPLATES, TEMPLATES_DIR
356
+
357
+ name = PDF_TEMPLATE_ALIASES.get(name, name)
358
+ if list_ or kind is None:
359
+ for t in PDF_TEMPLATES.values():
360
+ typer.echo(f"{t.name:12} {_(t.title)}")
361
+ typer.echo(f"{'':12} {_(t.description)}")
362
+ return
363
+ if out is None:
364
+ raise typer.BadParameter(_("specify -o/--output"))
365
+ base = Path(__file__).parent / "templates"
366
+ out.parent.mkdir(parents=True, exist_ok=True)
367
+ if kind == TemplateKind.pdf:
368
+ if name not in PDF_TEMPLATES:
369
+ raise typer.BadParameter(_("no template {name!r}; available: {known}", name=name,
370
+ known=", ".join(PDF_TEMPLATES)))
371
+ shutil.copyfile(PDF_TEMPLATES[name].path, out)
372
+ lib = out.parent / "lib.typ"
373
+ shutil.copyfile(TEMPLATES_DIR / "lib.typ", lib) # shared elements, imported by the template
374
+ _done([out, lib])
375
+ return
376
+ src = {"md": base / "document.md.j2", "html": base / "document.html.j2",
377
+ "xlsx-style": base / "xlsx_style.yaml"}[kind.value]
378
+ shutil.copyfile(src, out)
379
+ _done(out)
380
+
381
+
382
+ # --------------------------------------------------------------------------- #
383
+
384
+
385
+ @export_app.command("xlsx", help=_("Excel: addressing, contents, bit maps, changes."))
386
+ def export_xlsx_cmd(
387
+ path: Path = MapArg,
388
+ out: Optional[Path] = typer.Option(None, "-o", "--output"),
389
+ style: Optional[Path] = typer.Option(None, "--style", exists=True, help=_("YAML file with styling.")),
390
+ lang: Optional[str] = LangOpt,
391
+ force: bool = ForceOpt,
392
+ ) -> None:
393
+ _need("xlsx")
394
+ from .export.xlsx import XlsxStyle, export_xlsx
395
+
396
+ res = _load_checked(path, force)
397
+ _done(export_xlsx(res.map, _default_out(path, ".xlsx", out), XlsxStyle.load(style), lang))
398
+
399
+
400
+ @export_app.command("pdf", help=_("PDF via Typst: title page, contents, bit diagrams, tables; ESKD template."))
401
+ def export_pdf_cmd(
402
+ path: Path = MapArg,
403
+ out: Optional[Path] = typer.Option(None, "-o", "--output"),
404
+ template: Optional[str] = typer.Option(
405
+ None, "--template",
406
+ help=_("Built-in template (mapcan, gost-2.105) or a path to your own .typ.")),
407
+ font_path: list[Path] = typer.Option([], "--font-path", help=_("Folder with additional fonts.")),
408
+ lang: Optional[str] = LangOpt,
409
+ force: bool = ForceOpt,
410
+ ) -> None:
411
+ _need("pdf")
412
+ from .export.pdf import PdfError, export_pdf
413
+
414
+ res = _load_checked(path, force)
415
+ try:
416
+ _done(export_pdf(res.map, _default_out(path, ".pdf", out), template, font_path, lang))
417
+ except PdfError as e:
418
+ typer.secho(str(e), fg=typer.colors.RED, err=True)
419
+ raise typer.Exit(2) from e
420
+
421
+
422
+ @export_app.command("md", help=_("Markdown for a README or wiki."))
423
+ def export_md_cmd(
424
+ path: Path = MapArg,
425
+ out: Optional[Path] = typer.Option(None, "-o", "--output"),
426
+ template: Optional[Path] = typer.Option(None, "--template", exists=True, help=_("Custom Jinja2 template.")),
427
+ lang: Optional[str] = LangOpt,
428
+ force: bool = ForceOpt,
429
+ ) -> None:
430
+ from .export.text import export_text
431
+
432
+ res = _load_checked(path, force)
433
+ _done(export_text(res.map, _default_out(path, ".md", out), "md", template, lang))
434
+
435
+
436
+ @export_app.command("html", help=_("Self-contained HTML page with search."))
437
+ def export_html_cmd(
438
+ path: Path = MapArg,
439
+ out: Optional[Path] = typer.Option(None, "-o", "--output"),
440
+ template: Optional[Path] = typer.Option(None, "--template", exists=True, help=_("Custom Jinja2 template.")),
441
+ lang: Optional[str] = LangOpt,
442
+ force: bool = ForceOpt,
443
+ ) -> None:
444
+ from .export.text import export_text
445
+
446
+ res = _load_checked(path, force)
447
+ _done(export_text(res.map, _default_out(path, ".html", out), "html", template, lang))
448
+
449
+
450
+ @export_app.command("dbc", help=_("DBC (one file per bus)."))
451
+ def export_dbc_cmd(
452
+ path: Path = MapArg,
453
+ out: Optional[Path] = typer.Option(None, "-o", "--output",
454
+ help=_(".dbc file (single bus) or a folder.")),
455
+ bus: Optional[str] = typer.Option(None, "--bus", help=_("Only the given bus.")),
456
+ force: bool = ForceOpt,
457
+ ) -> None:
458
+ from .export.dbc import export_dbc
459
+
460
+ res = _load_checked(path, force)
461
+ _done(export_dbc(res.map, out or Path("."), bus, warn=_warn))
462
+
463
+
464
+ @export_app.command("eds", help=_(
465
+ "EDS (CiA 306) for every device of the CANopen buses: PDO objects and their mapping."))
466
+ def export_eds_cmd(
467
+ path: Path = MapArg,
468
+ out: Path = typer.Option(Path("."), "-o", "--output", help=_("Directory for the .eds files.")),
469
+ bus: Optional[str] = typer.Option(None, "--bus", help=_("Only the given bus.")),
470
+ force: bool = ForceOpt,
471
+ ) -> None:
472
+ from .export.eds import export_eds
473
+
474
+ res = _load_checked(path, force)
475
+ _done(export_eds(res.map, out, bus, warn=_warn))
476
+
477
+
478
+ @gen_app.command("c", help=_("C: #define IDs, enums, masks, structs, pack/unpack."))
479
+ def gen_c_cmd(
480
+ path: Path = MapArg,
481
+ out: Path = typer.Option(Path("."), "-o", "--output", help=_("Folder for .h/.c files.")),
482
+ prefix: str = typer.Option("", "--prefix", help=_("Name prefix (e.g. ne_).")),
483
+ bus: list[str] = typer.Option([], "--bus", help=_("Only the given buses.")),
484
+ force: bool = ForceOpt,
485
+ ) -> None:
486
+ from .codegen.c import generate_c
487
+
488
+ res = _load_checked(path, force)
489
+ _done(generate_c(res.map, out, prefix, bus or None))
490
+
491
+
492
+ @gen_app.command("py", help=_("Python module with the same structures (for tests and test benches)."))
493
+ def gen_py_cmd(
494
+ path: Path = MapArg,
495
+ out: Path = typer.Option(Path("."), "-o", "--output", help=_("Folder for the modules.")),
496
+ bus: list[str] = typer.Option([], "--bus", help=_("Only the given buses.")),
497
+ force: bool = ForceOpt,
498
+ ) -> None:
499
+ from .codegen.py import generate_py
500
+
501
+ res = _load_checked(path, force)
502
+ _done(generate_py(res.map, out, bus or None))
503
+
504
+
505
+ @import_app.command("dbc", help=_("DBC → YAML map: nodes, frames, signals, value tables, transmission modes."))
506
+ def import_dbc_cmd(
507
+ path: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help=_("DBC file.")),
508
+ out: Optional[Path] = typer.Option(None, "-o", "--output", help=_("YAML map file; <name>.yaml by default.")),
509
+ title: Optional[str] = typer.Option(None, "--title", help=_("Document title; the file name by default.")),
510
+ lang: str = typer.Option("en", "--lang", help=_("Document language (ISO 639-1 code).")),
511
+ force: bool = typer.Option(False, "--force", help=_("Overwrite an existing file.")),
512
+ ) -> None:
513
+ from .i18n import normalize_language
514
+ from .importers.dbc import import_dbc
515
+
516
+ target = _default_out(path, ".yaml", out)
517
+ if target.exists() and not force:
518
+ typer.secho(_("{path} already exists; give another -o or --force", path=target),
519
+ fg=typer.colors.RED, err=True)
520
+ raise typer.Exit(1)
521
+ doc_lang = normalize_language(lang)
522
+ if doc_lang is None:
523
+ raise typer.BadParameter(_("unknown language: {language}", language=lang))
524
+ text, warnings = import_dbc(path, title, doc_lang)
525
+ for w in warnings:
526
+ _warn(w)
527
+ target.parent.mkdir(parents=True, exist_ok=True)
528
+ target.write_text(text, encoding="utf-8")
529
+ _done(target)
530
+ _res, report = validate_file(target)
531
+ _print_report(report)
532
+ typer.echo(_summary(report))
533
+
534
+
535
+ ALL_FORMATS = ["xlsx", "pdf", "md", "html", "dbc", "eds", "c", "py"]
536
+
537
+
538
+ @app.command(help=_("Validate the map and build all artifacts (for CI)."))
539
+ def build(
540
+ path: Path = MapArg,
541
+ out: Path = typer.Option(Path("build"), "-o", "--output", help=_("Output folder.")),
542
+ formats: str = typer.Option(",".join(ALL_FORMATS), "--formats", help=_(
543
+ "Comma-separated: ") + ",".join(ALL_FORMATS)),
544
+ pdf_template: list[str] = typer.Option(
545
+ [], "--pdf-template", help=_("PDF template (may be repeated; mapcan by default).")),
546
+ force: bool = ForceOpt,
547
+ ) -> None:
548
+ from .codegen.c import generate_c
549
+ from .codegen.py import generate_py
550
+ from .export.dbc import export_dbc
551
+ from .export.eds import export_eds
552
+ from .export.text import export_text
553
+
554
+ wanted = [f.strip() for f in formats.split(",") if f.strip()]
555
+ unknown = set(wanted) - set(ALL_FORMATS)
556
+ if unknown:
557
+ raise typer.BadParameter(_("unknown formats: {formats}", formats=", ".join(sorted(unknown))))
558
+ _need(*(f for f in ("xlsx", "pdf") if f in wanted))
559
+
560
+ def export_xlsx(*a):
561
+ from .export.xlsx import export_xlsx as x
562
+
563
+ return x(*a)
564
+
565
+ def export_pdf(*a):
566
+ from .export.pdf import export_pdf as p
567
+
568
+ return p(*a)
569
+
570
+ res = _load_checked(path, force)
571
+ m, stem = res.map, path.stem
572
+ out.mkdir(parents=True, exist_ok=True)
573
+ steps = {
574
+ "xlsx": lambda: export_xlsx(m, out / f"{stem}.xlsx"),
575
+ "pdf": lambda: [export_pdf(m, out / (f"{stem}.pdf" if len(pdf_template) <= 1 else f"{stem}-{t}.pdf"), t)
576
+ for t in (pdf_template or ["mapcan"])],
577
+ "md": lambda: export_text(m, out / f"{stem}.md", "md"),
578
+ "html": lambda: export_text(m, out / f"{stem}.html", "html"),
579
+ "dbc": lambda: export_dbc(m, out / "dbc", warn=_warn),
580
+ "eds": lambda: export_eds(m, out / "eds", warn=_warn),
581
+ "c": lambda: generate_c(m, out / "c"),
582
+ "py": lambda: generate_py(m, out / "py"),
583
+ }
584
+ for f in wanted:
585
+ _done(steps[f]())
586
+
587
+
588
+ def main() -> None:
589
+ try:
590
+ sys.stdout.reconfigure(errors="replace")
591
+ sys.stderr.reconfigure(errors="replace")
592
+ except (AttributeError, ValueError):
593
+ pass
594
+ app()
595
+
596
+
597
+ if __name__ == "__main__":
598
+ main()
File without changes