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.
- mapcan/__init__.py +9 -0
- mapcan/bits.py +85 -0
- mapcan/canopen.py +64 -0
- mapcan/cli.py +598 -0
- mapcan/codegen/__init__.py +0 -0
- mapcan/codegen/c.py +451 -0
- mapcan/codegen/common.py +95 -0
- mapcan/codegen/py.py +277 -0
- mapcan/diagnostics.py +65 -0
- mapcan/diff.py +383 -0
- mapcan/docmodel.py +556 -0
- mapcan/examples/canopen-node.yaml +113 -0
- mapcan/examples/j1939.yaml +193 -0
- mapcan/examples/minimal.yaml +81 -0
- mapcan/examples/tractor/powertrain.yaml +103 -0
- mapcan/examples/tractor/tractor.yaml +52 -0
- mapcan/examples/vehicle-can.yaml +163 -0
- mapcan/export/__init__.py +0 -0
- mapcan/export/dbc.py +195 -0
- mapcan/export/eds.py +215 -0
- mapcan/export/pdf.py +144 -0
- mapcan/export/text.py +70 -0
- mapcan/export/xlsx.py +416 -0
- mapcan/extras.py +38 -0
- mapcan/fmt.py +316 -0
- mapcan/fonts/OFL-PTSerif.txt +93 -0
- mapcan/fonts/OFL.txt +93 -0
- mapcan/fonts/PT_Sans-Web-Bold.ttf +0 -0
- mapcan/fonts/PT_Sans-Web-BoldItalic.ttf +0 -0
- mapcan/fonts/PT_Sans-Web-Italic.ttf +0 -0
- mapcan/fonts/PT_Sans-Web-Regular.ttf +0 -0
- mapcan/fonts/PT_Serif-Web-Bold.ttf +0 -0
- mapcan/fonts/PT_Serif-Web-BoldItalic.ttf +0 -0
- mapcan/fonts/PT_Serif-Web-Italic.ttf +0 -0
- mapcan/fonts/PT_Serif-Web-Regular.ttf +0 -0
- mapcan/i18n.py +34 -0
- mapcan/importers/__init__.py +1 -0
- mapcan/importers/dbc.py +257 -0
- mapcan/launcher.py +332 -0
- mapcan/layout.py +46 -0
- mapcan/loader.py +583 -0
- mapcan/locale/__init__.py +1 -0
- mapcan/locale/ru.py +952 -0
- mapcan/lsp/__init__.py +1 -0
- mapcan/lsp/analysis.py +865 -0
- mapcan/lsp/edits.py +1213 -0
- mapcan/lsp/exports.py +57 -0
- mapcan/lsp/server.py +588 -0
- mapcan/model.py +802 -0
- mapcan/protocols.py +47 -0
- mapcan/resolve.py +118 -0
- mapcan/schema.py +37 -0
- mapcan/templates/document.html.j2 +207 -0
- mapcan/templates/document.md.j2 +151 -0
- mapcan/templates/pdf/gost-19.typ +188 -0
- mapcan/templates/pdf/gost-2.105.typ +242 -0
- mapcan/templates/pdf/gost-7.32.typ +196 -0
- mapcan/templates/pdf/lib.typ +332 -0
- mapcan/templates/pdf/mapcan.typ +117 -0
- mapcan/templates/xlsx_style.yaml +22 -0
- mapcan/units.py +61 -0
- mapcan/updates.py +85 -0
- mapcan/upgrade.py +159 -0
- mapcan/userconfig.py +98 -0
- mapcan/validate.py +853 -0
- mapcan/web/__init__.py +0 -0
- mapcan/web/app.py +1292 -0
- mapcan/web/examples.py +88 -0
- mapcan/web/newmap.py +160 -0
- mapcan/web/session.py +438 -0
- mapcan/web/static/OFL-Sora.txt +93 -0
- mapcan/web/static/assets/index-DRMUrO54.js +31 -0
- mapcan/web/static/assets/index-S8VlG2gr.css +1 -0
- mapcan/web/static/favicon.svg +7 -0
- mapcan/web/static/index.html +14 -0
- mapcan/web/yamlops.py +768 -0
- mapcan-0.21.0.dist-info/METADATA +492 -0
- mapcan-0.21.0.dist-info/RECORD +85 -0
- mapcan-0.21.0.dist-info/WHEEL +4 -0
- mapcan-0.21.0.dist-info/entry_points.txt +5 -0
- mapcan-0.21.0.dist-info/licenses/LICENSE +176 -0
- mapcan-0.21.0.dist-info/licenses/NOTICE +15 -0
- mapcan-0.21.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +89 -0
- mapcan-0.21.0.dist-info/licenses/src/mapcan/fonts/OFL-PTSerif.txt +93 -0
- 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
|