CANConfigPython 0.1.0a1__py3-none-win_amd64.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 (165) hide show
  1. canconfigpython/__init__.py +7 -0
  2. canconfigpython/__main__.py +3 -0
  3. canconfigpython/cli.py +111 -0
  4. canconfigpython/client.py +243 -0
  5. canconfigpython/commands.py +57 -0
  6. canconfigpython/errors.py +19 -0
  7. canconfigpython/resources/docs/native-source.tar.gz +0 -0
  8. canconfigpython/resources/docs/user-guide.en.md +274 -0
  9. canconfigpython/resources/docs/user-guide.zh-CN.md +265 -0
  10. canconfigpython/resources/skill/canconfig-python/SKILL.md +71 -0
  11. canconfigpython/resources/skill/canconfig-python/agents/openai.yaml +4 -0
  12. canconfigpython/resources/skill/canconfig-python/assets/changes.schema.json +832 -0
  13. canconfigpython/resources/skill/canconfig-python/assets/examples/bridge_dual_rates.json +18 -0
  14. canconfigpython/resources/skill/canconfig-python/assets/examples/bridge_mapping.json +13 -0
  15. canconfigpython/resources/skill/canconfig-python/assets/examples/cancom_115200_8n1.json +10 -0
  16. canconfigpython/resources/skill/canconfig-python/assets/examples/cancom_format_crc.json +8 -0
  17. canconfigpython/resources/skill/canconfig-python/assets/examples/cancom_gap.json +8 -0
  18. canconfigpython/resources/skill/canconfig-python/assets/examples/cancom_transparent_id.json +10 -0
  19. canconfigpython/resources/skill/canconfig-python/assets/examples/fb_1m.json +12 -0
  20. canconfigpython/resources/skill/canconfig-python/assets/examples/fb_500k.json +12 -0
  21. canconfigpython/resources/skill/canconfig-python/assets/examples/fb_adaptive_single.json +7 -0
  22. canconfigpython/resources/skill/canconfig-python/assets/examples/fb_return_manual.json +22 -0
  23. canconfigpython/resources/skill/canconfig-python/assets/examples/fb_silent_adaptive_option.json +7 -0
  24. canconfigpython/resources/skill/canconfig-python/assets/examples/filter_blacklist.json +7 -0
  25. canconfigpython/resources/skill/canconfig-python/assets/examples/filter_mask.json +12 -0
  26. canconfigpython/resources/skill/canconfig-python/assets/examples/filter_range.json +12 -0
  27. canconfigpython/resources/skill/canconfig-python/assets/examples/hub_h_external.json +13 -0
  28. canconfigpython/resources/skill/canconfig-python/assets/examples/hub_input_filter.json +13 -0
  29. canconfigpython/resources/skill/canconfig-python/assets/examples/hub_l_external.json +13 -0
  30. canconfigpython/resources/skill/canconfig-python/assets/examples/hub_mapping.json +46 -0
  31. canconfigpython/resources/skill/canconfig-python/assets/examples/hub_routing.json +8 -0
  32. canconfigpython/resources/skill/canconfig-python/assets/examples/index.en.md +55 -0
  33. canconfigpython/resources/skill/canconfig-python/assets/examples/index.json +661 -0
  34. canconfigpython/resources/skill/canconfig-python/assets/examples/index.zh-CN.md +55 -0
  35. canconfigpython/resources/skill/canconfig-python/assets/examples/negative/adaptive_mixed.json +8 -0
  36. canconfigpython/resources/skill/canconfig-python/assets/examples/negative/hub_missing_ps.json +12 -0
  37. canconfigpython/resources/skill/canconfig-python/assets/examples/negative/restricted_inheritance.json +12 -0
  38. canconfigpython/resources/skill/canconfig-python/assets/examples/negative/short_user_data.json +7 -0
  39. canconfigpython/resources/skill/canconfig-python/assets/examples/negative/string_numeric_id.json +7 -0
  40. canconfigpython/resources/skill/canconfig-python/assets/examples/negative/unknown_field.json +7 -0
  41. canconfigpython/resources/skill/canconfig-python/assets/examples/negative/unsupported_adaptive.json +7 -0
  42. canconfigpython/resources/skill/canconfig-python/assets/examples/negative/wrong_uid.json +7 -0
  43. canconfigpython/resources/skill/canconfig-python/assets/examples/rpt_adaptive_all.json +12 -0
  44. canconfigpython/resources/skill/canconfig-python/assets/examples/rpt_silent.json +7 -0
  45. canconfigpython/resources/skill/canconfig-python/assets/examples/user_data.json +7 -0
  46. canconfigpython/resources/skill/canconfig-python/references/operations.md +68 -0
  47. canconfigpython/resources.py +17 -0
  48. canconfigpython/runtime/Qt5Core.dll +0 -0
  49. canconfigpython/runtime/Qt5Network.dll +0 -0
  50. canconfigpython/runtime/Qt5SerialPort.dll +0 -0
  51. canconfigpython/runtime/WIICAN.dll +0 -0
  52. canconfigpython/runtime/canconfigconsole.exe +0 -0
  53. canconfigpython/runtime/libgcc_s_dw2-1.dll +0 -0
  54. canconfigpython/runtime/libstdc++-6.dll +0 -0
  55. canconfigpython/runtime/libwinpthread-1.dll +0 -0
  56. canconfigpython/runtime/manifest.json +19 -0
  57. canconfigpython/runtime.py +61 -0
  58. canconfigpython-0.1.0a1.dist-info/GPL-3.0.txt +674 -0
  59. canconfigpython-0.1.0a1.dist-info/LICENSE +21 -0
  60. canconfigpython-0.1.0a1.dist-info/METADATA +197 -0
  61. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-LICENSE.txt +866 -0
  62. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.FDL.txt +450 -0
  63. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.GPL2.txt +339 -0
  64. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.GPL3-EXCEPT.txt +704 -0
  65. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.GPL3.txt +674 -0
  66. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.GPLv3.txt +686 -0
  67. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.LGPL3.txt +165 -0
  68. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.LGPLv21.txt +514 -0
  69. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.LGPLv3.txt +175 -0
  70. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-LICENSE.PREVIEW.COMMERCIAL.txt +626 -0
  71. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__angle__LICENSE.txt +32 -0
  72. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__angle__src__libANGLE__renderer__d3d__d3d11__dxgi_support_data.json.txt +1164 -0
  73. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__angle__src__libANGLE__renderer__d3d__d3d11__load_functions_data.json.txt +1116 -0
  74. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__angle__src__libANGLE__renderer__d3d__d3d11__swizzle_format_data.json.txt +77 -0
  75. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__angle__src__libANGLE__renderer__d3d__d3d11__texture_format_data.json.txt +692 -0
  76. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__angle__src__third_party__compiler__LICENSE.txt +22 -0
  77. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__angle__src__third_party__murmurhash__LICENSE.txt +2 -0
  78. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__double-conversion__LICENSE.txt +26 -0
  79. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__easing__easing.cpp.txt +670 -0
  80. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__easing__legal.qdoc.txt +35 -0
  81. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__forkfd__forkfd.c.txt +875 -0
  82. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__forkfd__forkfd.h.txt +66 -0
  83. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__forkfd__forkfd_gcc.h.txt +63 -0
  84. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__freebsd__0001-Patch-the-FreeBSD-strto-u-ll-functions-to-work-insid.patch.txt +151 -0
  85. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__freebsd__strtoll.c.txt +134 -0
  86. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__freebsd__strtoull.c.txt +112 -0
  87. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__freetype__docs__LICENSE.TXT.txt +37 -0
  88. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__freetype__src__tools__no-copyright.txt +61 -0
  89. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__freetype__src__tools__update-copyright-year.txt +135 -0
  90. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__freetype__src__tools__update-copyright.txt +14 -0
  91. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__harfbuzz-ng__COPYING.txt +36 -0
  92. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__harfbuzz__COPYING.txt +24 -0
  93. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__libpng__LICENSE.txt +112 -0
  94. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__md4__md4.cpp.txt +265 -0
  95. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__md4__md4.h.txt +31 -0
  96. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__md5__md5.cpp.txt +247 -0
  97. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__md5__md5.h.txt +48 -0
  98. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__pcre__COPYING.txt +5 -0
  99. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__pcre__LICENCE.txt +93 -0
  100. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__rfc6234__patches__0001-Fix-compilation-MSVC-warning-in-3rdparty-rfc6234.patch.txt +86 -0
  101. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__rfc6234__sha-private.h.txt +28 -0
  102. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__rfc6234__sha.h.txt +357 -0
  103. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__rfc6234__sha224-256.c.txt +583 -0
  104. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__rfc6234__sha384-512.c.txt +1034 -0
  105. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha1__sha1.cpp.txt +261 -0
  106. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-32-rvk.macros.txt +555 -0
  107. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-32.macros.txt +26 -0
  108. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-64.macros.txt +728 -0
  109. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-int-set.h.txt +6 -0
  110. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-interface.h.txt +46 -0
  111. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-opt32-settings.h.txt +4 -0
  112. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-opt32.c.txt +524 -0
  113. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-opt64-settings.h.txt +7 -0
  114. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-opt64.c.txt +512 -0
  115. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakF-1600-unrolling.macros.txt +124 -0
  116. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakNISTInterface.c.txt +82 -0
  117. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakNISTInterface.h.txt +70 -0
  118. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakSponge.c.txt +266 -0
  119. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__KeccakSponge.h.txt +80 -0
  120. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__sha3__brg_endian.h.txt +142 -0
  121. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtbase-src__3rdparty__xkbcommon__COPYING.txt +188 -0
  122. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.FDL.txt +450 -0
  123. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.GPL2.txt +339 -0
  124. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.GPL3-EXCEPT.txt +704 -0
  125. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.GPL3.txt +674 -0
  126. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.GPLv2.txt +292 -0
  127. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.GPLv3.txt +686 -0
  128. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.LGPL3.txt +165 -0
  129. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.LGPLv21.txt +514 -0
  130. canconfigpython-0.1.0a1.dist-info/Qt-5.7.1-qtserialport-LICENSE.LGPLv3.txt +175 -0
  131. canconfigpython-0.1.0a1.dist-info/RECORD +165 -0
  132. canconfigpython-0.1.0a1.dist-info/THIRD_PARTY_NOTICES.md +69 -0
  133. canconfigpython-0.1.0a1.dist-info/WHEEL +5 -0
  134. canconfigpython-0.1.0a1.dist-info/entry_points.txt +3 -0
  135. canconfigpython-0.1.0a1.dist-info/gcc-COPYING.RUNTIME.txt +73 -0
  136. canconfigpython-0.1.0a1.dist-info/gcc-COPYING3.LIB.txt +165 -0
  137. canconfigpython-0.1.0a1.dist-info/gcc-COPYING3.txt +674 -0
  138. canconfigpython-0.1.0a1.dist-info/linux-libc6-copyright.txt +529 -0
  139. canconfigpython-0.1.0a1.dist-info/linux-libcap2-copyright.txt +112 -0
  140. canconfigpython-0.1.0a1.dist-info/linux-libcom-err2-copyright.txt +403 -0
  141. canconfigpython-0.1.0a1.dist-info/linux-libdouble-conversion3-copyright.txt +41 -0
  142. canconfigpython-0.1.0a1.dist-info/linux-libgcc-s1-copyright.txt +1532 -0
  143. canconfigpython-0.1.0a1.dist-info/linux-libglib2.0-0t64-copyright.txt +1250 -0
  144. canconfigpython-0.1.0a1.dist-info/linux-libgssapi-krb5-2-copyright.txt +1374 -0
  145. canconfigpython-0.1.0a1.dist-info/linux-libicu74-copyright.txt +49 -0
  146. canconfigpython-0.1.0a1.dist-info/linux-libk5crypto3-copyright.txt +1374 -0
  147. canconfigpython-0.1.0a1.dist-info/linux-libkeyutils1-copyright.txt +52 -0
  148. canconfigpython-0.1.0a1.dist-info/linux-libkrb5-3-copyright.txt +1374 -0
  149. canconfigpython-0.1.0a1.dist-info/linux-libkrb5support0-copyright.txt +1374 -0
  150. canconfigpython-0.1.0a1.dist-info/linux-libpcre2-16-0-copyright.txt +122 -0
  151. canconfigpython-0.1.0a1.dist-info/linux-libpcre2-8-0-copyright.txt +122 -0
  152. canconfigpython-0.1.0a1.dist-info/linux-libqt5core5t64-copyright.txt +2932 -0
  153. canconfigpython-0.1.0a1.dist-info/linux-libqt5network5t64-copyright.txt +2932 -0
  154. canconfigpython-0.1.0a1.dist-info/linux-libqt5serialport5-copyright.txt +134 -0
  155. canconfigpython-0.1.0a1.dist-info/linux-libstdc++6-copyright.txt +1532 -0
  156. canconfigpython-0.1.0a1.dist-info/linux-libudev1-copyright.txt +287 -0
  157. canconfigpython-0.1.0a1.dist-info/linux-libzstd1-copyright.txt +123 -0
  158. canconfigpython-0.1.0a1.dist-info/linux-packages.json +102 -0
  159. canconfigpython-0.1.0a1.dist-info/linux-zlib1g-copyright.txt +84 -0
  160. canconfigpython-0.1.0a1.dist-info/mingw-w64-COPYING.MinGW-w64-runtime.txt.txt +240 -0
  161. canconfigpython-0.1.0a1.dist-info/mingw-w64-COPYING.MinGW-w64.txt.txt +135 -0
  162. canconfigpython-0.1.0a1.dist-info/mingw-w64-COPYING.txt +43 -0
  163. canconfigpython-0.1.0a1.dist-info/qt-source-notices.json +278 -0
  164. canconfigpython-0.1.0a1.dist-info/top_level.txt +1 -0
  165. canconfigpython-0.1.0a1.dist-info/winpthreads-COPYING.txt +57 -0
@@ -0,0 +1,7 @@
1
+ """CANConfigPython: subprocess-isolated CAN device configuration."""
2
+ __version__ = "0.1.0a1"
3
+
4
+ from .client import Client
5
+ from .errors import ConfigError
6
+
7
+ __all__ = ["Client", "ConfigError", "__version__"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
canconfigpython/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+ from . import __version__
6
+ from .client import Client
7
+ from .commands import COMMANDS, AUTH, BOOLS
8
+ from .errors import ConfigError, envelope
9
+ from .resources import export
10
+
11
+
12
+ class Parser(argparse.ArgumentParser):
13
+ def error(self, message):
14
+ # argparse's raw error can echo sensitive accidental positional arguments.
15
+ raise ConfigError("invalid_arguments", "Invalid, duplicate or missing arguments; use canconfig --help or canconfig COMMAND --help.")
16
+
17
+
18
+ def parser():
19
+ p = Parser(prog="canconfig", description="CANConfigPython: bundled Qt/WIICAN console backend.", allow_abbrev=False)
20
+ p.add_argument("--version", action="store_true")
21
+ p.add_argument("--json", action="store_true", help="emit one compact UTF-8 JSON result")
22
+ sub = p.add_subparsers(dest="command", parser_class=Parser)
23
+ descriptions = {
24
+ "info": "Read identity and capabilities; connection may change device run mode.",
25
+ "validate": "Validate an INI backup offline.",
26
+ "export": "Export an INI backup; a successful export replaces an existing file.",
27
+ "restore": "Restore an INI backup and verify readback.",
28
+ "apply": "Apply a UTF-8 JSON patch and verify readback.",
29
+ "change-password": "Change the password of the explicitly identified device.",
30
+ "firmware-update": "Transfer an .sfb firmware package to a device already in update mode.",
31
+ "schema": "Show the descriptive backend contract (not a JSON Schema document).",
32
+ }
33
+ for name, (allowed, required) in COMMANDS.items():
34
+ command = sub.add_parser(name, help=descriptions[name], description=descriptions[name], allow_abbrev=False)
35
+ command.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
36
+ for option in sorted(allowed):
37
+ kwargs = {"required": option in required, "default": argparse.SUPPRESS}
38
+ if option in BOOLS:
39
+ kwargs["action"] = "store_true"
40
+ kwargs["help"] = "firmware: file-only checks; configuration: live preflight" if option == "dry_run" else None
41
+ elif option == "timeout_ms":
42
+ kwargs["type"] = int
43
+ kwargs["help"] = "backend deadline in milliseconds (default 120000)"
44
+ command.add_argument("--" + option.replace("_", "-"), **kwargs)
45
+ if name in AUTH:
46
+ command.add_argument("--password-env", required=True, help="name of an environment variable; never the password itself")
47
+ if name == "change-password":
48
+ command.add_argument("--new-password-env", required=True)
49
+ doctor = sub.add_parser("doctor", help="Check bundled libraries without opening devices.", allow_abbrev=False)
50
+ doctor.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
51
+ for name in ("docs", "skill"):
52
+ group = sub.add_parser(name, help="Export bundled " + name + " to a new directory.", allow_abbrev=False)
53
+ actions = group.add_subparsers(dest="action", required=True, parser_class=Parser)
54
+ action = actions.add_parser("export", allow_abbrev=False)
55
+ action.add_argument("--output", required=True)
56
+ action.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
57
+ return p
58
+
59
+
60
+ def _emit(result, compact):
61
+ text = json.dumps(result, ensure_ascii=False, allow_nan=False, indent=None if compact else 2) + "\n"
62
+ if hasattr(sys.stdout, "buffer"):
63
+ sys.stdout.buffer.write(text.encode("utf-8"))
64
+ sys.stdout.buffer.flush()
65
+ else:
66
+ sys.stdout.write(text)
67
+
68
+
69
+ def main(argv=None):
70
+ argv = list(sys.argv[1:] if argv is None else argv)
71
+ compact = "--json" in argv
72
+ command = ""
73
+ try:
74
+ seen = set()
75
+ for arg in argv:
76
+ if arg.startswith("--"):
77
+ key = arg.split("=", 1)[0]
78
+ if key in seen:
79
+ raise ConfigError("duplicate_argument", "An option was specified more than once.")
80
+ seen.add(key)
81
+ args = vars(parser().parse_args(argv))
82
+ compact = args.pop("json")
83
+ command = args.pop("command") or ""
84
+ version = args.pop("version")
85
+ if version:
86
+ if command:
87
+ raise ConfigError("invalid_arguments", "--version cannot be combined with a command.")
88
+ result = envelope("version", data={"version": __version__, "backend": "2.3", "bridge_api": 1})
89
+ elif command == "doctor":
90
+ result = Client().doctor()
91
+ elif command in ("docs", "skill"):
92
+ result = envelope(command, data=export(command, args["output"]))
93
+ elif command:
94
+ old = args.pop("password_env", None)
95
+ new = args.pop("new_password_env", None)
96
+ if new and old.casefold() == new.casefold():
97
+ raise ConfigError("invalid_arguments", "Old and new password environment names must differ.")
98
+ password = os.environ.get(old) if old else None
99
+ new_password = os.environ.get(new) if new else None
100
+ result = Client().execute(command, password=password, new_password=new_password, **args)
101
+ else:
102
+ parser().print_help()
103
+ return 0
104
+ except ConfigError as exc:
105
+ result = envelope(command, exc.exit_code, exc.code, exc.message)
106
+ except KeyboardInterrupt:
107
+ result = envelope(command, 12, "cancelled", "Cancelled before starting a device operation.")
108
+ except OSError:
109
+ result = envelope(command, 3, "local_io_failed", "Local file access failed before device execution.")
110
+ _emit(result, compact)
111
+ return result["exit_code"]
@@ -0,0 +1,243 @@
1
+ import contextlib
2
+ import hashlib
3
+ import json
4
+ import math
5
+ import os
6
+ from pathlib import Path
7
+ import signal
8
+ import stat
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from . import commands
13
+ from .errors import ConfigError, envelope
14
+ from .runtime import inspect_runtime, child_environment, BACKEND_VERSION, BRIDGE_API
15
+
16
+ MAX_PATCH = 8 * 1024 * 1024
17
+
18
+
19
+ def _unique(pairs):
20
+ result = {}
21
+ for key, value in pairs:
22
+ if key in result:
23
+ raise ValueError("duplicate key")
24
+ result[key] = value
25
+ return result
26
+
27
+
28
+ def _constant(value):
29
+ raise ValueError("nonfinite number")
30
+
31
+
32
+ def strict_json(data):
33
+ def finite(value):
34
+ number = float(value)
35
+ if not math.isfinite(number):
36
+ raise ValueError("nonfinite number")
37
+ return number
38
+ return json.loads(data, object_pairs_hook=_unique, parse_constant=_constant, parse_float=finite)
39
+
40
+
41
+ def _password(value):
42
+ try:
43
+ valid = isinstance(value, str) and 1 <= len(value.encode("utf-8")) <= 10 and "\x00" not in value
44
+ except UnicodeError:
45
+ valid = False
46
+ if not valid:
47
+ raise ConfigError("invalid_credentials", "Passwords must contain 1 to 10 UTF-8 bytes without NUL.", 2)
48
+ return value
49
+
50
+
51
+ @contextlib.contextmanager
52
+ def _port_lock(port):
53
+ """Per-user OS lock shared by all Python wrapper instances. Never unlink it."""
54
+ user = str(os.getuid()) if hasattr(os, "getuid") else os.environ.get("USERNAME", "")
55
+ digest = hashlib.sha256((user + "\0" + port).encode("utf-8")).hexdigest()
56
+ path = Path(tempfile.gettempdir()) / ("canconfigpython-" + digest + ".lock")
57
+ try:
58
+ stream = open(str(path), "a+b")
59
+ except OSError:
60
+ raise ConfigError("lock_failed", "Cannot create the per-user device lock.", 1)
61
+ locked = False
62
+ try:
63
+ if os.name == "nt":
64
+ import msvcrt
65
+ stream.seek(0, os.SEEK_END)
66
+ if stream.tell() == 0:
67
+ stream.write(b"\0")
68
+ stream.flush()
69
+ stream.seek(0)
70
+ try:
71
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
72
+ locked = True
73
+ except OSError:
74
+ pass
75
+ else:
76
+ import fcntl
77
+ try:
78
+ fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
79
+ locked = True
80
+ except OSError:
81
+ pass
82
+ if not locked:
83
+ raise ConfigError("device_busy", "Another CANConfigPython operation is using this port.", 1)
84
+ yield
85
+ finally:
86
+ if locked:
87
+ if os.name == "nt":
88
+ stream.seek(0)
89
+ msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
90
+ else:
91
+ fcntl.flock(stream, fcntl.LOCK_UN)
92
+ stream.close()
93
+
94
+
95
+ def _stop(process):
96
+ try:
97
+ process.send_signal(signal.CTRL_BREAK_EVENT if os.name == "nt" else signal.SIGINT)
98
+ return process.communicate(timeout=5)
99
+ except (OSError, subprocess.TimeoutExpired, KeyboardInterrupt):
100
+ try:
101
+ process.kill()
102
+ except OSError:
103
+ pass # Process may have exited between signal and kill.
104
+ return process.communicate()
105
+
106
+
107
+ def _run(executable, command, args, env, timeout, hardware):
108
+ kwargs = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == "nt" else {"start_new_session": True}
109
+ try:
110
+ process = subprocess.Popen(
111
+ [str(executable), command] + args + ["--json"],
112
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
113
+ env=env, shell=False, **kwargs)
114
+ except OSError:
115
+ return envelope(command, 1, "backend_start_failed", "Cannot start the bundled backend; run canconfig doctor.")
116
+ stopped = None
117
+ try:
118
+ output, _stderr = process.communicate(timeout=timeout)
119
+ except subprocess.TimeoutExpired:
120
+ stopped = "backend_timeout"
121
+ output, _stderr = _stop(process)
122
+ except KeyboardInterrupt:
123
+ stopped = "cancelled"
124
+ output, _stderr = _stop(process)
125
+ # Never echo unstructured driver diagnostics or arguments containing input data.
126
+ try:
127
+ result = strict_json(output.decode("utf-8"))
128
+ if (not isinstance(result, dict) or type(result.get("schema_version")) is not int or result.get("schema_version") != 1
129
+ or result.get("command") != command
130
+ or type(result.get("exit_code")) is not int
131
+ or not 0 <= result["exit_code"] <= 12
132
+ or type(result.get("ok")) is not bool
133
+ or result["ok"] != (result["exit_code"] == 0)
134
+ or process.returncode != result["exit_code"]
135
+ or not isinstance(result.get("data"), dict)
136
+ or not isinstance(result.get("verification"), dict)
137
+ or not isinstance(result.get("effects"), dict)):
138
+ raise ValueError("envelope")
139
+ effects = result["effects"]
140
+ if effects.get("status") not in ("none", "partial", "unknown", "applied", "transferred"):
141
+ raise ValueError("effects")
142
+ for key in ("attempted", "submitted", "acknowledged", "save_acknowledged"):
143
+ if type(effects.get(key)) is not int or effects[key] < 0:
144
+ raise ValueError("count")
145
+ return result
146
+ except (UnicodeError, ValueError, TypeError, KeyError):
147
+ code = 8 if hardware else 12 if stopped == "cancelled" else 11 if stopped else 4
148
+ return envelope(command, code, stopped or "backend_protocol_error",
149
+ "No valid final backend result. Stop; do not automatically retry or roll back.",
150
+ unknown=hardware)
151
+
152
+
153
+ class Client:
154
+ """Synchronous API; one backend process per call. No automatic retries.
155
+
156
+ execute() returns the complete result including nonzero backend exit codes.
157
+ Local validation/runtime failures raise ConfigError before device execution.
158
+ """
159
+ def __init__(self):
160
+ self._runtime = None
161
+
162
+ def _prepare(self):
163
+ return inspect_runtime(self._runtime)
164
+
165
+ def doctor(self):
166
+ executable, manifest = self._prepare()
167
+ result = _run(executable, "runtime-check", [], child_environment(executable.parent), 15, False)
168
+ result["command"] = "doctor"
169
+ if result["ok"] and (result["data"].get("version") != BACKEND_VERSION
170
+ or result["data"].get("bridge_api") != BRIDGE_API):
171
+ return envelope("doctor", 1, "backend_version_mismatch", "Bundled backend has an incompatible contract.")
172
+ result["data"]["runtime_manifest"] = manifest
173
+ return result
174
+
175
+ def execute(self, command, *, password=None, new_password=None, **options):
176
+ options = commands.validate(command, options)
177
+ secrets = []
178
+ credential_args = []
179
+ if command in commands.AUTH:
180
+ secrets.append(("CANCONFIGPY_PASSWORD", _password(password)))
181
+ credential_args += ["--password-env", "CANCONFIGPY_PASSWORD"]
182
+ elif password is not None:
183
+ raise ConfigError("invalid_arguments", "This command does not accept a password.")
184
+ if command == "change-password":
185
+ secrets.append(("CANCONFIGPY_NEW_PASSWORD", _password(new_password)))
186
+ credential_args += ["--new-password-env", "CANCONFIGPY_NEW_PASSWORD"]
187
+ elif new_password is not None:
188
+ raise ConfigError("invalid_arguments", "Only change-password accepts a new password.")
189
+ executable, _manifest = self._prepare()
190
+ # Check protocol and dynamic dependencies offline before any device operation.
191
+ probe = self.doctor()
192
+ if not probe["ok"]:
193
+ probe["command"] = command
194
+ return probe
195
+ hardware = command in commands.HARDWARE and not (
196
+ command == "firmware-update" and options.get("dry_run", False))
197
+ if hardware and sys.platform.startswith("linux"):
198
+ try:
199
+ selected = Path(options["port"]).resolve(strict=True)
200
+ if not str(selected).startswith("/dev/") or not stat.S_ISCHR(selected.stat().st_mode):
201
+ raise OSError("not a serial character device")
202
+ options["port"] = str(selected)
203
+ except OSError:
204
+ raise ConfigError("port_not_found", "Selected /dev/ serial path is missing or is not a character device.", 1)
205
+ with contextlib.ExitStack() as stack:
206
+ if hardware:
207
+ stack.enter_context(_port_lock(options["port"]))
208
+ if command == "apply":
209
+ try:
210
+ with open(options["file"], "rb") as stream:
211
+ source = stream.read(MAX_PATCH + 1)
212
+ if len(source) > MAX_PATCH:
213
+ raise ValueError("size")
214
+ patch = strict_json(source.decode("utf-8"))
215
+ if (not isinstance(patch, dict) or set(patch) != {"schema_version", "device_uid", "changes"}
216
+ or type(patch["schema_version"]) is not int or patch["schema_version"] != 1
217
+ or not isinstance(patch["device_uid"], str)
218
+ or not isinstance(patch["changes"], dict)):
219
+ raise ValueError("shape")
220
+ encoded = json.dumps(patch, ensure_ascii=False, allow_nan=False).encode("utf-8")
221
+ except OSError:
222
+ raise ConfigError("file_read_failed", "Cannot read the JSON patch.", 3)
223
+ except (UnicodeError, ValueError, RecursionError):
224
+ raise ConfigError("invalid_patch", "Use a UTF-8 JSON patch without BOM, duplicate keys or nonfinite numbers; required keys: schema_version, device_uid, changes. Maximum 8 MiB.", 7)
225
+ # Pass an immutable snapshot of exactly the JSON that passed strict parsing.
226
+ directory = stack.enter_context(tempfile.TemporaryDirectory(prefix="canconfig-patch-"))
227
+ snapshot = Path(directory) / "changes.json"
228
+ snapshot.write_bytes(encoded)
229
+ options["file"] = str(snapshot)
230
+ args = list(credential_args)
231
+ for key, value in options.items():
232
+ if value is False:
233
+ continue
234
+ args.append("--" + key.replace("_", "-"))
235
+ if value is not True:
236
+ args.append(str(value))
237
+ env = child_environment(executable.parent, secrets)
238
+ try:
239
+ return _run(executable, command, args, env,
240
+ options.get("timeout_ms", 120000) / 1000.0 + 8, hardware)
241
+ finally:
242
+ for key, _value in secrets:
243
+ env.pop(key, None)
@@ -0,0 +1,57 @@
1
+ from pathlib import Path
2
+ import re
3
+ import sys
4
+ from .errors import ConfigError
5
+
6
+ COMMON = {"timeout_ms"}
7
+ DEVICE = COMMON | {"port", "expect_uid"}
8
+ AUTH = {"export", "restore", "apply", "change-password"}
9
+ HARDWARE = AUTH | {"info", "firmware-update"}
10
+ COMMANDS = {
11
+ "info": (DEVICE, {"port"}),
12
+ "validate": (COMMON | {"file", "sections"}, {"file"}),
13
+ "export": (DEVICE | {"file", "sections"}, {"port", "file"}),
14
+ "restore": (DEVICE | {"file", "sections", "dry_run", "check_device"}, {"port", "file"}),
15
+ "apply": (DEVICE | {"file", "dry_run"}, {"port", "file"}),
16
+ "change-password": (DEVICE | {"dry_run"}, {"port", "expect_uid"}),
17
+ "firmware-update": (COMMON | {"port", "file", "dry_run", "expect_sha256", "expect_usb_serial", "no_dtr"}, {"port", "file"}),
18
+ "schema": (set(), set()),
19
+ }
20
+ BOOLS = {"dry_run", "check_device", "no_dtr"}
21
+
22
+
23
+ def validate(command, options):
24
+ if command not in COMMANDS:
25
+ raise ConfigError("invalid_command", "Unknown command; use canconfig --help.")
26
+ allowed, required = COMMANDS[command]
27
+ if set(options) - allowed or not required <= set(options):
28
+ raise ConfigError("invalid_arguments", "Missing or unsupported options for this command.")
29
+ result = {}
30
+ for key, value in options.items():
31
+ if key in BOOLS:
32
+ if type(value) is not bool:
33
+ raise ConfigError("invalid_arguments", "Boolean options require true or false.")
34
+ elif key == "timeout_ms":
35
+ if type(value) is not int or not 1 <= value <= 3600000:
36
+ raise ConfigError("invalid_timeout", "timeout_ms must be between 1 and 3600000.")
37
+ else:
38
+ if isinstance(value, Path):
39
+ value = str(value)
40
+ if not isinstance(value, str) or not value or "\x00" in value or value.startswith("--"):
41
+ raise ConfigError("invalid_arguments", "Options require nonempty strings without NUL.")
42
+ result[key] = value
43
+ if "port" in result:
44
+ value = result["port"]
45
+ if sys.platform == "win32":
46
+ match = re.fullmatch(r"(?:COM)?([0-9]+)", value, re.IGNORECASE)
47
+ if not match or not 1 <= int(match[1]) <= 65535:
48
+ raise ConfigError("invalid_port", "Use an explicit COM port, for example COM3.")
49
+ result["port"] = "COM" + str(int(match[1]))
50
+ elif sys.platform.startswith("linux"):
51
+ if not value.startswith("/dev/") or ".." in Path(value).parts:
52
+ raise ConfigError("invalid_port", "Use an absolute /dev/ serial device path.")
53
+ else:
54
+ raise ConfigError("unsupported_platform", "This release supports Windows and Linux.", 5)
55
+ if "expect_uid" in result and not re.fullmatch(r"[0-9a-fA-F]{4}(?:-[0-9a-fA-F]{4}){5}", result["expect_uid"]):
56
+ raise ConfigError("invalid_uid", "UID must contain six groups of four hexadecimal digits.")
57
+ return result
@@ -0,0 +1,19 @@
1
+ class ConfigError(Exception):
2
+ """A local error before any backend operation was started."""
3
+ def __init__(self, code, message, exit_code=9):
4
+ super().__init__(message)
5
+ self.code, self.message, self.exit_code = code, message, exit_code
6
+
7
+
8
+ def envelope(command, code=0, error=None, message=None, data=None, unknown=False):
9
+ count = None if unknown else 0
10
+ result = {
11
+ "schema_version": 1, "command": command, "ok": code == 0, "exit_code": code,
12
+ "data": data or {},
13
+ "effects": {key: count for key in ("attempted", "submitted", "acknowledged", "save_acknowledged")},
14
+ "verification": {"readback": "not_verified", "power_cycle": "not_verified"},
15
+ }
16
+ result["effects"]["status"] = "unknown" if unknown else "none"
17
+ if error:
18
+ result["error"] = {"code": error, "message": message, "automatic_retry_allowed": False}
19
+ return result