mapcan 0.23.2__tar.gz → 0.23.3__tar.gz

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 (157) hide show
  1. {mapcan-0.23.2 → mapcan-0.23.3}/.gitignore +4 -0
  2. {mapcan-0.23.2 → mapcan-0.23.3}/PKG-INFO +4 -1
  3. {mapcan-0.23.2 → mapcan-0.23.3}/README.md +3 -0
  4. {mapcan-0.23.2 → mapcan-0.23.3}/docs/ci-example.md +42 -0
  5. {mapcan-0.23.2 → mapcan-0.23.3}/docs/cli.md +18 -1
  6. {mapcan-0.23.2 → mapcan-0.23.3}/docs/web.md +5 -1
  7. {mapcan-0.23.2 → mapcan-0.23.3}/pyproject.toml +1 -1
  8. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/__init__.py +1 -1
  9. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/cli.py +14 -0
  10. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/diff.py +53 -3
  11. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/launcher.py +4 -4
  12. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/locale/ru.py +5 -0
  13. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/lsp/server.py +20 -0
  14. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/app.py +38 -5
  15. mapcan-0.23.3/src/mapcan/web/static/assets/index-B4RJ5ml1.js +36 -0
  16. mapcan-0.23.2/src/mapcan/web/static/assets/index-qvZIhvUc.css → mapcan-0.23.3/src/mapcan/web/static/assets/index-BXiCvm0s.css +1 -1
  17. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/static/index.html +2 -2
  18. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_diff.py +44 -1
  19. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_lsp.py +15 -0
  20. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_workspace.py +32 -1
  21. mapcan-0.23.2/src/mapcan/web/static/assets/index-B4ld-hyP.js +0 -35
  22. {mapcan-0.23.2 → mapcan-0.23.3}/LICENSE +0 -0
  23. {mapcan-0.23.2 → mapcan-0.23.3}/NOTICE +0 -0
  24. {mapcan-0.23.2 → mapcan-0.23.3}/THIRD_PARTY_NOTICES.md +0 -0
  25. {mapcan-0.23.2 → mapcan-0.23.3}/docs/format-changelog.md +0 -0
  26. {mapcan-0.23.2 → mapcan-0.23.3}/docs/format.md +0 -0
  27. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/demo-dark.gif +0 -0
  28. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/demo-light.gif +0 -0
  29. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/document-pages-dark.png +0 -0
  30. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/document-pages-light.png +0 -0
  31. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-demo-dark.gif +0 -0
  32. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-demo-light.gif +0 -0
  33. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-hover-dark.png +0 -0
  34. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-hover-light.png +0 -0
  35. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-map-dark.png +0 -0
  36. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-map-light.png +0 -0
  37. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-problems-dark.png +0 -0
  38. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-problems-light.png +0 -0
  39. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-quickfix-dark.gif +0 -0
  40. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/vscode-quickfix-light.gif +0 -0
  41. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/web-frame-dark.png +0 -0
  42. {mapcan-0.23.2 → mapcan-0.23.3}/docs/images/web-frame-light.png +0 -0
  43. {mapcan-0.23.2 → mapcan-0.23.3}/docs/logo-dark.svg +0 -0
  44. {mapcan-0.23.2 → mapcan-0.23.3}/docs/logo-light.svg +0 -0
  45. {mapcan-0.23.2 → mapcan-0.23.3}/docs/templates.md +0 -0
  46. {mapcan-0.23.2 → mapcan-0.23.3}/docs/zephyr.md +0 -0
  47. {mapcan-0.23.2 → mapcan-0.23.3}/examples/canopen-node.yaml +0 -0
  48. {mapcan-0.23.2 → mapcan-0.23.3}/examples/j1939.yaml +0 -0
  49. {mapcan-0.23.2 → mapcan-0.23.3}/examples/minimal.yaml +0 -0
  50. {mapcan-0.23.2 → mapcan-0.23.3}/examples/tractor/powertrain.yaml +0 -0
  51. {mapcan-0.23.2 → mapcan-0.23.3}/examples/tractor/tractor.yaml +0 -0
  52. {mapcan-0.23.2 → mapcan-0.23.3}/examples/vehicle-can.yaml +0 -0
  53. {mapcan-0.23.2 → mapcan-0.23.3}/examples/zephyr/CMakeLists.txt +0 -0
  54. {mapcan-0.23.2 → mapcan-0.23.3}/examples/zephyr/prj.conf +0 -0
  55. {mapcan-0.23.2 → mapcan-0.23.3}/examples/zephyr/src/main.c +0 -0
  56. {mapcan-0.23.2 → mapcan-0.23.3}/schema/mapcan-part.schema.json +0 -0
  57. {mapcan-0.23.2 → mapcan-0.23.3}/schema/mapcan.schema.json +0 -0
  58. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/bits.py +0 -0
  59. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/canopen.py +0 -0
  60. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/codegen/__init__.py +0 -0
  61. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/codegen/c.py +0 -0
  62. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/codegen/common.py +0 -0
  63. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/codegen/py.py +0 -0
  64. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/codegen/zephyr.py +0 -0
  65. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/diagnostics.py +0 -0
  66. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/docmodel.py +0 -0
  67. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/export/__init__.py +0 -0
  68. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/export/dbc.py +0 -0
  69. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/export/eds.py +0 -0
  70. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/export/pdf.py +0 -0
  71. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/export/text.py +0 -0
  72. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/export/xlsx.py +0 -0
  73. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/extras.py +0 -0
  74. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fmt.py +0 -0
  75. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/OFL-PTSerif.txt +0 -0
  76. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/OFL.txt +0 -0
  77. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/PT_Sans-Web-Bold.ttf +0 -0
  78. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/PT_Sans-Web-BoldItalic.ttf +0 -0
  79. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/PT_Sans-Web-Italic.ttf +0 -0
  80. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/PT_Sans-Web-Regular.ttf +0 -0
  81. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/PT_Serif-Web-Bold.ttf +0 -0
  82. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/PT_Serif-Web-BoldItalic.ttf +0 -0
  83. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/PT_Serif-Web-Italic.ttf +0 -0
  84. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/fonts/PT_Serif-Web-Regular.ttf +0 -0
  85. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/i18n.py +0 -0
  86. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/importers/__init__.py +0 -0
  87. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/importers/dbc.py +0 -0
  88. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/layout.py +0 -0
  89. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/loader.py +0 -0
  90. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/locale/__init__.py +0 -0
  91. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/lsp/__init__.py +0 -0
  92. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/lsp/analysis.py +0 -0
  93. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/lsp/edits.py +0 -0
  94. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/lsp/exports.py +0 -0
  95. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/model.py +0 -0
  96. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/protocols.py +0 -0
  97. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/resolve.py +0 -0
  98. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/schema.py +0 -0
  99. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/templates/document.html.j2 +0 -0
  100. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/templates/document.md.j2 +0 -0
  101. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/templates/pdf/gost-19.typ +0 -0
  102. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/templates/pdf/gost-2.105.typ +0 -0
  103. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/templates/pdf/gost-7.32.typ +0 -0
  104. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/templates/pdf/lib.typ +0 -0
  105. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/templates/pdf/mapcan.typ +0 -0
  106. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/templates/xlsx_style.yaml +0 -0
  107. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/units.py +0 -0
  108. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/updates.py +0 -0
  109. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/upgrade.py +0 -0
  110. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/userconfig.py +0 -0
  111. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/validate.py +0 -0
  112. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/__init__.py +0 -0
  113. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/devicefile.py +0 -0
  114. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/examples.py +0 -0
  115. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/newmap.py +0 -0
  116. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/session.py +0 -0
  117. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/static/OFL-Sora.txt +0 -0
  118. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/static/favicon.svg +0 -0
  119. {mapcan-0.23.2 → mapcan-0.23.3}/src/mapcan/web/yamlops.py +0 -0
  120. {mapcan-0.23.2 → mapcan-0.23.3}/tests/__init__.py +0 -0
  121. {mapcan-0.23.2 → mapcan-0.23.3}/tests/conftest.py +0 -0
  122. {mapcan-0.23.2 → mapcan-0.23.3}/tests/data/invalid_map.yaml +0 -0
  123. {mapcan-0.23.2 → mapcan-0.23.3}/tests/data/legacy.yaml +0 -0
  124. {mapcan-0.23.2 → mapcan-0.23.3}/tests/data/logo.png +0 -0
  125. {mapcan-0.23.2 → mapcan-0.23.3}/tests/data/logo.svg +0 -0
  126. {mapcan-0.23.2 → mapcan-0.23.3}/tests/data/reference.yaml +0 -0
  127. {mapcan-0.23.2 → mapcan-0.23.3}/tests/data/zephyr_stub/zephyr/drivers/can.h +0 -0
  128. {mapcan-0.23.2 → mapcan-0.23.3}/tests/data/zephyr_stub/zephyr/kernel.h +0 -0
  129. {mapcan-0.23.2 → mapcan-0.23.3}/tests/golden/reference.md +0 -0
  130. {mapcan-0.23.2 → mapcan-0.23.3}/tests/golden/reference_bus1.h +0 -0
  131. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_auth.py +0 -0
  132. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_cli.py +0 -0
  133. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_codegen.py +0 -0
  134. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_device_file.py +0 -0
  135. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_direction.py +0 -0
  136. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_eds.py +0 -0
  137. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_exchange.py +0 -0
  138. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_export.py +0 -0
  139. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_extras.py +0 -0
  140. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_fmt.py +0 -0
  141. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_i18n.py +0 -0
  142. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_import.py +0 -0
  143. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_include.py +0 -0
  144. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_j1939.py +0 -0
  145. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_launcher.py +0 -0
  146. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_loader_fast.py +0 -0
  147. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_lsp_edits.py +0 -0
  148. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_model.py +0 -0
  149. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_new_signal.py +0 -0
  150. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_source.py +0 -0
  151. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_templates.py +0 -0
  152. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_titles.py +0 -0
  153. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_units.py +0 -0
  154. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_upgrade.py +0 -0
  155. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_validate.py +0 -0
  156. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_web.py +0 -0
  157. {mapcan-0.23.2 → mapcan-0.23.3}/tests/test_yamlops.py +0 -0
@@ -41,3 +41,7 @@ canmap_tasks.md
41
41
 
42
42
  # UI mockups
43
43
  examples_ui/
44
+
45
+ # Browser tests of the web editor
46
+ web/test-results/
47
+ web/playwright-report/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mapcan
3
- Version: 0.23.2
3
+ Version: 0.23.3
4
4
  Summary: A YAML map of CAN signals as the single source of truth: validation, Excel/PDF/Markdown/HTML, DBC, C/Python code
5
5
  Project-URL: Homepage, https://github.com/ordu1453/mapcan
6
6
  Project-URL: Documentation, https://github.com/ordu1453/mapcan/blob/main/docs/format.md
@@ -125,6 +125,9 @@ mapcan build vehicle.yaml -o build/ # PDF, Excel, HTML, DBC, C and Pytho
125
125
  mapcan app vehicle.yaml # edit it on the bit layout
126
126
  ```
127
127
 
128
+ The app, the web editor (**File → Import DBC…**) and the VS Code extension (**mapcan: Import DBC…**)
129
+ import a DBC too.
130
+
128
131
  You keep your tools: the DBC exported back works in CANalyzer, SavvyCAN, PCAN-View and Wireshark.
129
132
 
130
133
  ## Why not just a DBC file?
@@ -53,6 +53,9 @@ mapcan build vehicle.yaml -o build/ # PDF, Excel, HTML, DBC, C and Pytho
53
53
  mapcan app vehicle.yaml # edit it on the bit layout
54
54
  ```
55
55
 
56
+ The app, the web editor (**File → Import DBC…**) and the VS Code extension (**mapcan: Import DBC…**)
57
+ import a DBC too.
58
+
56
59
  You keep your tools: the DBC exported back works in CANalyzer, SavvyCAN, PCAN-View and Wireshark.
57
60
 
58
61
  ## Why not just a DBC file?
@@ -45,6 +45,48 @@ jobs:
45
45
  merge. `mapcan fmt --check` fails when the keys of the map are not in the canonical order, so that maps
46
46
  edited by different people and editors stay easy to review; `mapcan fmt my-map.yaml` fixes it.
47
47
 
48
+ ## Breaking changes in pull requests
49
+
50
+ A map describes devices already in the field: moving a signal, changing its scale or reassigning an
51
+ ID breaks them, while a new signal in free bits does not. `mapcan diff --fail-on breaking` tells the
52
+ two apart (see [cli.md](cli.md#comparing-versions)), so a pull request that breaks the map fails
53
+ unless it says so on purpose: here with the label `breaking`, or by raising the major version of
54
+ `document.version`.
55
+
56
+ ```yaml
57
+ name: mapcan-compat
58
+ on:
59
+ pull_request:
60
+ paths: ["my-map.yaml"]
61
+ types: [opened, synchronize, reopened, labeled, unlabeled]
62
+
63
+ jobs:
64
+ compat:
65
+ runs-on: ubuntu-latest
66
+ if: ${{ !contains(github.event.pull_request.labels.*.name, 'breaking') }}
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+ with:
70
+ fetch-depth: 0
71
+ - uses: astral-sh/setup-uv@v5
72
+ - run: uv tool install "mapcan==X.Y.Z"
73
+ - name: Compare with the base branch
74
+ shell: bash # with pipefail, so that tee keeps the exit code of mapcan diff
75
+ run: |
76
+ git show "origin/${{ github.base_ref }}:my-map.yaml" > base.yaml
77
+ old=$(yq '.document.version' base.yaml | cut -d. -f1)
78
+ new=$(yq '.document.version' my-map.yaml | cut -d. -f1)
79
+ if [ "$new" != "$old" ]; then
80
+ echo "major version raised ($old → $new): breaking changes allowed"
81
+ mapcan diff base.yaml my-map.yaml --lang en
82
+ else
83
+ mapcan diff base.yaml my-map.yaml --lang en --format md --fail-on breaking | tee -a "$GITHUB_STEP_SUMMARY"
84
+ fi
85
+ ```
86
+
87
+ `git show` gives only the main file: a map split into included files needs the base branch checked
88
+ out next to it (`git worktree add ../base origin/main`) and `mapcan diff ../base/my-map.yaml my-map.yaml`.
89
+
48
90
  ## GitLab CI
49
91
 
50
92
  `.gitlab-ci.yml`, using the published Docker image:
@@ -16,7 +16,7 @@ mapcan export eds FILE [-o DIR] [--bus NAME]
16
16
  mapcan gen c FILE [-o DIR] [--prefix my_] [--bus NAME]
17
17
  mapcan gen py FILE [-o DIR] [--bus NAME]
18
18
  mapcan build FILE [-o DIR] [--formats xlsx,pdf,md,html,dbc,eds,c,py] [--pdf-template NAME]
19
- mapcan diff OLD NEW [--format text|md|json]
19
+ mapcan diff OLD NEW [--format text|md|json] [--fail-on breaking|any]
20
20
  mapcan import dbc FILE.dbc [-o FILE.yaml] [--title TEXT] [--lang en] [--force]
21
21
  mapcan migrate FILE… [--check] # former field names → current ones
22
22
  mapcan fmt FILE… [--check] # keys in one order, comments and layout kept
@@ -188,6 +188,23 @@ becomes a `can` bus with explicit IDs. Value names become identifiers (`Not avai
188
188
  `NOT_AVAILABLE`, the original text goes to the description), and whatever DBC cannot express is
189
189
  reported as a warning.
190
190
 
191
+ ## Comparing versions
192
+
193
+ `mapcan diff OLD NEW` lists what changed between two versions of a map and marks the changes that
194
+ break devices built against the old one (`[breaking]` in text, **breaking:** in Markdown, a
195
+ `breaking` field per change in JSON):
196
+
197
+ | Breaking | Compatible |
198
+ |---|---|
199
+ | a bus, frame, signal, flag or value removed | anything added: buses, devices, frames, signals in free bits, values |
200
+ | a frame's ID, CANopen function, PGN, priority, length or IDE changed; a device's node ID or address | timing, transmission mode, sender and receivers |
201
+ | a signal moved or resized; its byte order, representation, encoding, factor or offset changed | titles, descriptions, units of display, initial values |
202
+ | a value renamed or renumbered; a flag moved; the multiplexer or a multiplexer value changed | limits widened or removed |
203
+ | a limit narrowed or added | |
204
+
205
+ `--fail-on breaking` exits with code 1 when a breaking change is found, `--fail-on any` on any change;
206
+ [ci-example.md](ci-example.md#breaking-changes-in-pull-requests) uses it to check pull requests.
207
+
191
208
  ## CI
192
209
 
193
210
  [ci-example.md](ci-example.md) shows a pipeline for map repositories: when the YAML changes, the map
@@ -47,6 +47,10 @@ Save as… / Close map** all work in the browser. The file dialog can browse any
47
47
  accepts a typed path and lists recent files. If the current map has unsaved changes, the editor
48
48
  offers to save them before switching.
49
49
 
50
+ **File → Import DBC…** (also on the start screen) turns a DBC file into a map, as
51
+ `mapcan import dbc` does: the map is written next to the DBC with its name (the editor asks before
52
+ overwriting) and opens; whatever the DBC cannot express is listed once it is open.
53
+
50
54
  The start screen also lists the bundled examples: pick one, choose where to save a copy, and it
51
55
  opens. The mapcan version is shown in the top bar next to the title and on the start screen.
52
56
  **File → Report an issue** (and the link on the start screen) opens a new GitHub issue with the
@@ -177,7 +181,7 @@ available at `http://127.0.0.1:8765/api/docs`.
177
181
  | GET | `/api/update` | Newer version on GitHub Releases: `{current, latest, newer, url, installer}` (`mapcan app` and local `serve` only) |
178
182
  | POST | `/api/instance/activate` | `{token, path}`: a second launch hands the file to the running instance (the token is in `~/.mapcan/instance.json`) |
179
183
  | GET | `/api/fs/list?dir=` | Folders and YAML files for the file dialog |
180
- | POST | `/api/file/open`, `/api/file/new`, `/api/file/example`, `/api/file/save_as`, `/api/file/close` | File operations; `409 unsaved` means there are unsaved changes (pass `force: true` to proceed) |
184
+ | POST | `/api/file/open`, `/api/file/new`, `/api/file/example`, `/api/file/import_dbc`, `/api/file/save_as`, `/api/file/close` | File operations; `409 unsaved` means there are unsaved changes (pass `force: true` to proceed) |
181
185
  | GET | `/api/state` | Text, data, computed layout and diagnostics |
182
186
  | POST | `/api/ops` | `{"ops": [...]}`: apply operations atomically |
183
187
  | POST | `/api/device_file` | `{"bus": ["buses", 0], "device": {...}, "file": "devices/node.yaml"}`: a new device in a new file, added to `include` |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mapcan"
3
- version = "0.23.2"
3
+ version = "0.23.3"
4
4
  description = "A YAML map of CAN signals as the single source of truth: validation, Excel/PDF/Markdown/HTML, DBC, C/Python code"
5
5
  # readme: built from README.md with absolute links for PyPI (see fancy-pypi-readme below)
6
6
  dynamic = ["readme"]
@@ -1,6 +1,6 @@
1
1
  """mapcan: a YAML signal map as the single source of truth."""
2
2
 
3
- __version__ = "0.23.2"
3
+ __version__ = "0.23.3"
4
4
 
5
5
  from .loader import load, loads # noqa: E402
6
6
  from .model import MapCan # noqa: E402
@@ -208,12 +208,20 @@ class DiffFormat(str, Enum):
208
208
  json = "json"
209
209
 
210
210
 
211
+ class FailOn(str, Enum):
212
+ breaking = "breaking"
213
+ any = "any"
214
+
215
+
211
216
  @app.command(help=_("Compare two versions of a map (frames, signals, values) and print a changelog."))
212
217
  def diff(
213
218
  old: Path = typer.Argument(..., exists=True, dir_okay=False, help=_("Old version of the map.")),
214
219
  new: Path = typer.Argument(..., exists=True, dir_okay=False, help=_("New version of the map.")),
215
220
  fmt: DiffFormat = typer.Option(DiffFormat.text, "--format", help="text | md | json"),
216
221
  lang: Optional[str] = LangOpt,
222
+ fail_on: Optional[FailOn] = typer.Option(
223
+ None, "--fail-on",
224
+ help=_("Exit code 1 on breaking changes (breaking) or on any change (any).")),
217
225
  ) -> None:
218
226
  from .diff import diff_maps, render_diff
219
227
  from .loader import load
@@ -228,6 +236,12 @@ def diff(
228
236
  maps.append(res.map)
229
237
  d = diff_maps(maps[0], maps[1], lang)
230
238
  typer.echo(render_diff(d, fmt.value, lang or maps[1].lang))
239
+ if fail_on is FailOn.breaking and d.breaking:
240
+ typer.secho(_("breaking changes: {count}", count=len(d.breaking)), fg=typer.colors.RED, err=True)
241
+ raise typer.Exit(1)
242
+ if fail_on is FailOn.any and not d.empty:
243
+ typer.secho(_("changes: {count}", count=len(d.changes)), fg=typer.colors.RED, err=True)
244
+ raise typer.Exit(1)
231
245
 
232
246
 
233
247
  @app.command(help=_("Rewrite the former names of fields (deprecated-key) to the current ones, keeping comments and "
@@ -27,6 +27,7 @@ class Change:
27
27
  field: str = "" # changed field
28
28
  old: Any = None
29
29
  new: Any = None
30
+ breaking: bool = False # devices built against the old version misread the bus (see is_breaking)
30
31
 
31
32
 
32
33
  @dataclass
@@ -39,6 +40,10 @@ class MapDiff:
39
40
  def empty(self) -> bool:
40
41
  return not self.changes
41
42
 
43
+ @property
44
+ def breaking(self) -> list[Change]:
45
+ return [c for c in self.changes if c.breaking]
46
+
42
47
  def to_dict(self) -> dict:
43
48
  return {"old_version": self.old_version, "new_version": self.new_version,
44
49
  "changes": [asdict(c) for c in self.changes]}
@@ -56,6 +61,45 @@ SIGNAL_FIELDS = ("start_bit", "bit_length", "byte_order", "representation", "enc
56
61
  "min", "max", "initial", "source", "invalid_raw", "multiplexer", "mux", "description")
57
62
  FLAG_FIELDS = ("bit", "bit_length", "description")
58
63
 
64
+ # Fields whose change makes a device built against the old version send or read the bus differently
65
+ BREAKING_FIELDS = {
66
+ "device": {"node_id", "address"},
67
+ "frame": {"can_id", "canopen", "length", "extended", "pgn", "priority"},
68
+ "signal": {"start_bit", "bit_length", "byte_order", "representation", "encoding", "factor", "offset",
69
+ "multiplexer", "mux"},
70
+ "flag": {"bit", "bit_length"},
71
+ "value": {"name"},
72
+ }
73
+
74
+
75
+ def _narrowed(fld: str, old: Any, new: Any) -> bool:
76
+ """A limit (min or max) admits fewer values than before; a limit set where there was none does too."""
77
+ if new is None:
78
+ return False
79
+ if old is None:
80
+ return True
81
+ try:
82
+ return new > old if fld == "min" else new < old
83
+ except TypeError:
84
+ return True
85
+
86
+
87
+ def is_breaking(c: Change) -> bool:
88
+ """The change breaks devices built against the old version of the map.
89
+
90
+ Removing a bus, frame, signal, flag or value breaks them; so do moving or rescaling a signal, changing the
91
+ identifier or length of a frame, renaming or renumbering a value and narrowing a limit. Additions, texts,
92
+ units of display, timing and widened limits do not. A removed device is not breaking by itself: its frames
93
+ are listed as removed.
94
+ """
95
+ if c.kind == "added":
96
+ return False
97
+ if c.kind == "removed":
98
+ return c.scope != "device"
99
+ if c.scope == "signal" and c.field in ("min", "max"):
100
+ return _narrowed(c.field, c.old, c.new)
101
+ return c.field in BREAKING_FIELDS.get(c.scope, ())
102
+
59
103
 
60
104
  def _invalid_text(s: Signal) -> str | None:
61
105
  """invalid_raw as one comparable text: "not_available 0xFF; error 0xFE00..0xFEFF"."""
@@ -104,6 +148,8 @@ def diff_maps(old: MapCan, new: MapCan, lang: str | None = None) -> MapDiff:
104
148
  d.changes.append(_frame_change("added", name, f))
105
149
  continue
106
150
  _diff_bus(d, ob[name], nb[name], langs)
151
+ for c in d.changes:
152
+ c.breaking = is_breaking(c)
107
153
  return d
108
154
 
109
155
 
@@ -254,14 +300,14 @@ _T = {
254
300
  "bus": "шина", "device": "устройство", "frame": "кадр", "signal": "сигнал",
255
301
  "flag": "флаг", "value": "значение", "bits": "биты", "byte": "байт",
256
302
  "added_n": "добавлено", "removed_n": "удалено", "changed_n": "изменено",
257
- "none": "Изменений нет.",
303
+ "none": "Изменений нет.", "breaking": "несовместимо",
258
304
  },
259
305
  "en": {
260
306
  "added": "added", "removed": "removed", "changed": "changed",
261
307
  "bus": "bus", "device": "device", "frame": "frame", "signal": "signal",
262
308
  "flag": "flag", "value": "value", "bits": "bits", "byte": "byte",
263
309
  "added_n": "added", "removed_n": "removed", "changed_n": "changed",
264
- "none": "No changes.",
310
+ "none": "No changes.", "breaking": "breaking",
265
311
  },
266
312
  }
267
313
 
@@ -375,9 +421,13 @@ def release_message(version: str, changes: list[str]) -> str:
375
421
  def render_diff(diff: MapDiff, fmt: str = "text", lang: str = "ru") -> str:
376
422
  if fmt == "json":
377
423
  return diff.to_json()
424
+ T = _T.get(lang, _T["ru"])
378
425
  lines = changelog_lines(diff, lang)
379
426
  if not lines:
380
- return _T.get(lang, _T["ru"])["none"]
427
+ return T["none"]
428
+ # changelog_lines gives one line per change, in the same order
429
+ mark = f"**{T['breaking']}:** " if fmt == "md" else f"[{T['breaking']}] "
430
+ lines = [mark + line if c.breaking else line for c, line in zip(diff.changes, lines, strict=True)]
381
431
  if fmt == "md":
382
432
  head = f"## {diff.old_version} → {diff.new_version}\n\n"
383
433
  return head + "\n".join(f"- {line}" for line in lines)
@@ -179,13 +179,13 @@ def _webview():
179
179
  class DialogApi:
180
180
  """Methods for the window's JS (window.pywebview.api): the system open and save file dialogs."""
181
181
 
182
- FILE_TYPES = ("YAML (*.yaml;*.yml)", "All files (*.*)")
182
+ FILE_TYPES = {"yaml": ("YAML (*.yaml;*.yml)", "All files (*.*)"), "dbc": ("DBC (*.dbc)", "All files (*.*)")}
183
183
 
184
184
  def __init__(self, get_window):
185
185
  self._get_window = get_window # with "_": pywebview does not expose the attribute to JS
186
186
 
187
- def pick_file(self, mode: str, directory: str = "", filename: str = "") -> str | None:
188
- """``mode``: "open" or "save". Returns the chosen path or None (cancelled)."""
187
+ def pick_file(self, mode: str, directory: str = "", filename: str = "", types: str = "yaml") -> str | None:
188
+ """``mode``: "open" or "save"; ``types``: a key of FILE_TYPES. Returns the chosen path or None (cancelled)."""
189
189
  import webview
190
190
 
191
191
  win = self._get_window()
@@ -193,7 +193,7 @@ class DialogApi:
193
193
  return None
194
194
  kind = webview.FileDialog.SAVE if mode == "save" else webview.FileDialog.OPEN
195
195
  result = win.create_file_dialog(kind, directory=directory or "", save_filename=filename or "",
196
- file_types=self.FILE_TYPES)
196
+ file_types=self.FILE_TYPES.get(types, self.FILE_TYPES["yaml"]))
197
197
  if not result:
198
198
  return None
199
199
  return result if isinstance(result, str) else result[0]
@@ -21,6 +21,10 @@ RU: dict[str, str] = {
21
21
  "Редактор в отдельном окне (pywebview); повторный запуск открывает файл в уже запущенном окне.",
22
22
  "Compare two versions of a map (frames, signals, values) and print a changelog.":
23
23
  "Сравнить две версии карты (кадры, сигналы, значения) и вывести changelog.",
24
+ "Exit code 1 on breaking changes (breaking) or on any change (any).":
25
+ "Код возврата 1 при несовместимых изменениях (breaking) или при любых (any).",
26
+ "breaking changes: {count}": "несовместимых изменений: {count}",
27
+ "changes: {count}": "изменений: {count}",
24
28
  "List validation rules.": "Список правил проверки.",
25
29
  "Print the format's JSON Schema (for IDE hints).": "Вывести JSON Schema формата (для подсказок в IDE).",
26
30
  "List PDF templates or copy a built-in template to customize the layout.":
@@ -623,6 +627,7 @@ RU: dict[str, str] = {
623
627
  "{fmt} export error: {error}": "ошибка экспорта {fmt}: {error}",
624
628
  "the file is not UTF-8 encoded": "файл не в UTF-8",
625
629
  "could not read {name}: {error}": "не удалось прочитать {name}: {error}",
630
+ "could not import {name}: {error}": "не удалось импортировать {name}: {error}",
626
631
  "could not create {path}: {error}": "не удалось создать {path}: {error}",
627
632
  "could not save {path}: {error}": "не удалось сохранить {path}: {error}",
628
633
  "could not save the logo: {error}": "не удалось сохранить логотип: {error}",
@@ -404,6 +404,26 @@ def new_map_command(ls: MapcanServer, *args) -> dict:
404
404
  return {"error": str(e)}
405
405
 
406
406
 
407
+ @server.command("mapcan.lsp.importDbc")
408
+ @server.thread()
409
+ def import_dbc_command(ls: MapcanServer, *args) -> dict:
410
+ """Argument: {path, title, language}: a DBC file. Response: {text, warnings} (the YAML of the map and what the
411
+ import could not bring over) or {error}."""
412
+ from ..i18n import normalize_language
413
+ from ..importers.dbc import import_dbc
414
+
415
+ params = _arg(args)
416
+ path = Path(str(params.get("path") or ""))
417
+ if not path.is_file():
418
+ return {"error": _("file not found: {path}", path=path)}
419
+ try:
420
+ text, warnings = import_dbc(path, params.get("title") or None,
421
+ normalize_language(str(params.get("language") or "en")) or "en")
422
+ except Exception as e: # noqa: BLE001 — cantools raises many kinds of errors on a broken DBC
423
+ return {"error": _("could not import {name}: {error}", name=path.name, error=e)}
424
+ return {"text": text, "warnings": warnings}
425
+
426
+
407
427
  @server.command("mapcan.lsp.example")
408
428
  def example_command(ls: MapcanServer, *args) -> dict:
409
429
  """Argument: {name, mapFile}: an example and the file name of its copy. Response: {text, includes: {path
@@ -120,6 +120,14 @@ class ExampleBody(BaseModel):
120
120
  overwrite: bool = False
121
121
 
122
122
 
123
+ class ImportBody(BaseModel):
124
+ source: str # the DBC file
125
+ path: str = "" # the new map; next to the DBC, with its name, by default
126
+ language: str = "en" # document language of the new map
127
+ force: bool = False
128
+ overwrite: bool = False
129
+
130
+
123
131
  class SaveAsBody(BaseModel):
124
132
  path: str
125
133
  overwrite: bool = False
@@ -314,8 +322,12 @@ class Workspace:
314
322
  }
315
323
 
316
324
 
317
- def list_dir(d: Path) -> dict:
318
- """Contents of a folder for the file dialog: subfolders and YAML files."""
325
+ FILE_KINDS = {"yaml": (".yaml", ".yml"), "dbc": (".dbc",)}
326
+
327
+
328
+ def list_dir(d: Path, kind: str = "yaml") -> dict:
329
+ """Contents of a folder for the file dialog: subfolders and the files of ``kind`` (FILE_KINDS)."""
330
+ suffixes = FILE_KINDS.get(kind, FILE_KINDS["yaml"])
319
331
  if not d.is_dir():
320
332
  raise AppError(404, "not-found", _("folder not found: {path}", path=d))
321
333
  try:
@@ -328,7 +340,7 @@ def list_dir(d: Path) -> dict:
328
340
  continue
329
341
  try:
330
342
  is_dir = it.is_dir()
331
- if not is_dir and it.suffix.lower() not in (".yaml", ".yml"):
343
+ if not is_dir and it.suffix.lower() not in suffixes:
332
344
  continue
333
345
  st = it.stat()
334
346
  except OSError:
@@ -680,8 +692,8 @@ def create_app(session: EditorSession | None = None, poll_interval: float = 1.0,
680
692
  return path
681
693
 
682
694
  @app.get("/api/fs/list")
683
- async def fs_list(dir: str = "") -> dict:
684
- return list_dir(wsp.resolve(dir) if dir else wsp.cwd)
695
+ async def fs_list(dir: str = "", kind: str = "yaml") -> dict:
696
+ return list_dir(wsp.resolve(dir) if dir else wsp.cwd, kind)
685
697
 
686
698
  @app.post("/api/file/open")
687
699
  async def file_open(body: OpenBody) -> dict:
@@ -738,6 +750,27 @@ def create_app(session: EditorSession | None = None, poll_interval: float = 1.0,
738
750
  return await create_file(body.path, body.force, body.overwrite, lambda _path: example_text(body.name),
739
751
  included)
740
752
 
753
+ @app.post("/api/file/import_dbc")
754
+ async def file_import_dbc(body: ImportBody) -> dict:
755
+ from ..importers.dbc import import_dbc
756
+
757
+ source = wsp.resolve(body.source)
758
+ if not source.is_file():
759
+ raise AppError(404, "not-found", _("file not found: {path}", path=source))
760
+ warnings: list[str] = []
761
+
762
+ def make_text(path: Path) -> str:
763
+ try:
764
+ text, found = import_dbc(source, path.stem, normalize_language(body.language) or "en")
765
+ except Exception as e: # noqa: BLE001 — cantools raises many kinds of errors on a broken DBC
766
+ raise AppError(400, "import", _("could not import {name}: {error}", name=source.name, error=e)) from e
767
+ warnings.extend(found)
768
+ return text
769
+
770
+ result = await create_file(body.path or str(source.with_suffix(".yaml")), body.force, body.overwrite,
771
+ make_text)
772
+ return {**result, "warnings": warnings}
773
+
741
774
  @app.post("/api/file/save_as")
742
775
  async def file_save_as(body: SaveAsBody) -> dict:
743
776
  writable()