motorcortex-python 0.25.4__tar.gz → 1.0.0__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 (48) hide show
  1. motorcortex_python-1.0.0/PKG-INFO +171 -0
  2. motorcortex_python-1.0.0/README.md +146 -0
  3. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex/__init__.py +164 -30
  4. motorcortex_python-1.0.0/motorcortex/_connection_state.py +58 -0
  5. motorcortex_python-1.0.0/motorcortex/_request_builders.py +157 -0
  6. motorcortex_python-1.0.0/motorcortex/_request_utils.py +314 -0
  7. motorcortex_python-1.0.0/motorcortex/_subscribe_dispatch.py +90 -0
  8. motorcortex_python-1.0.0/motorcortex/exceptions.py +65 -0
  9. motorcortex_python-1.0.0/motorcortex/init_threads.py +103 -0
  10. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex/message_types.py +91 -76
  11. motorcortex_python-1.0.0/motorcortex/motorcortex_hash.json +166 -0
  12. motorcortex_python-1.0.0/motorcortex/motorcortex_pb2.py +105 -0
  13. motorcortex_python-1.0.0/motorcortex/motorcortex_pb2.pyi +1961 -0
  14. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex/nng_url.py +12 -6
  15. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex/parameter_tree.py +22 -13
  16. motorcortex_python-1.0.0/motorcortex/py.typed +0 -0
  17. motorcortex_python-1.0.0/motorcortex/reply.py +108 -0
  18. motorcortex_python-1.0.0/motorcortex/request.py +668 -0
  19. motorcortex_python-1.0.0/motorcortex/session.py +194 -0
  20. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex/state_callback_handler.py +10 -5
  21. motorcortex_python-1.0.0/motorcortex/subscribe.py +400 -0
  22. motorcortex_python-1.0.0/motorcortex/subscription.py +414 -0
  23. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex/timespec.py +12 -20
  24. motorcortex_python-1.0.0/motorcortex/version.py +1 -0
  25. motorcortex_python-1.0.0/motorcortex_python.egg-info/PKG-INFO +171 -0
  26. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex_python.egg-info/SOURCES.txt +8 -0
  27. motorcortex_python-1.0.0/motorcortex_python.egg-info/requires.txt +2 -0
  28. motorcortex_python-1.0.0/pyproject.toml +51 -0
  29. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/setup.py +21 -1
  30. motorcortex_python-0.25.4/PKG-INFO +0 -33
  31. motorcortex_python-0.25.4/README.md +0 -11
  32. motorcortex_python-0.25.4/motorcortex/init_threads.py +0 -20
  33. motorcortex_python-0.25.4/motorcortex/motorcortex_hash.json +0 -166
  34. motorcortex_python-0.25.4/motorcortex/motorcortex_pb2.py +0 -103
  35. motorcortex_python-0.25.4/motorcortex/reply.py +0 -74
  36. motorcortex_python-0.25.4/motorcortex/request.py +0 -980
  37. motorcortex_python-0.25.4/motorcortex/subscribe.py +0 -373
  38. motorcortex_python-0.25.4/motorcortex/subscription.py +0 -319
  39. motorcortex_python-0.25.4/motorcortex/version.py +0 -1
  40. motorcortex_python-0.25.4/motorcortex_python.egg-info/PKG-INFO +0 -33
  41. motorcortex_python-0.25.4/motorcortex_python.egg-info/requires.txt +0 -2
  42. motorcortex_python-0.25.4/pyproject.toml +0 -3
  43. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/LICENSE +0 -0
  44. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/MANIFEST.in +0 -0
  45. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex/setup_logger.py +0 -0
  46. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex_python.egg-info/dependency_links.txt +0 -0
  47. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/motorcortex_python.egg-info/top_level.txt +0 -0
  48. {motorcortex_python-0.25.4 → motorcortex_python-1.0.0}/setup.cfg +0 -0
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.1
2
+ Name: motorcortex-python
3
+ Version: 1.0.0
4
+ Summary: Python bindings for Motorcortex Engine
5
+ Home-page: https://www.motorcortex.io
6
+ Author: Alexey Zakharov
7
+ Author-email: alexey.zakharov@vectioneer.com
8
+ License: MIT
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: System :: Distributed Computing
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: pynng<2,>=0.9.0
24
+ Requires-Dist: protobuf>=3.20
25
+
26
+ # motorcortex-python
27
+
28
+ [![pipeline status](https://git.vectioneer.com/pub/motorcortex-python/badges/master/pipeline.svg)](https://git.vectioneer.com/pub/motorcortex-python/-/commits/master)
29
+ [![coverage report](https://git.vectioneer.com/pub/motorcortex-python/badges/master/coverage.svg?job=coverage)](https://git.vectioneer.com/pub/motorcortex-python/-/commits/master)
30
+ [![PyPI version](https://img.shields.io/pypi/v/motorcortex-python.svg)](https://pypi.org/project/motorcortex-python/)
31
+ [![Python versions](https://img.shields.io/pypi/pyversions/motorcortex-python.svg)](https://pypi.org/project/motorcortex-python/)
32
+ [![License: MIT](https://img.shields.io/pypi/l/motorcortex-python.svg)](LICENSE)
33
+
34
+ Python bindings for the [Motorcortex](https://www.motorcortex.io) real-time control engine. Connect to a Motorcortex server, read and write parameters, and stream live telemetry over a single TLS-secured websocket.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install motorcortex-python
40
+ ```
41
+
42
+ Requires Python ≥ 3.10. Runtime dependencies (`pynng`, `protobuf`) are installed automatically.
43
+
44
+ ## Quick start
45
+
46
+ Recommended — `Session` context manager (close is automatic, even on exceptions):
47
+
48
+ ```python
49
+ import motorcortex
50
+
51
+ with motorcortex.Session(
52
+ "wss://192.168.2.100",
53
+ certificate="mcx.cert.crt",
54
+ login="admin", password="admin",
55
+ timeout_ms=1000,
56
+ ) as s:
57
+ # Read a single parameter
58
+ reply = s.req.getParameter("root/Control/dummyDouble").get()
59
+ print(reply.value)
60
+
61
+ # Write a parameter
62
+ s.req.setParameter("root/Control/dummyDouble", 3.14).get()
63
+
64
+ # Subscribe to a streamed update (every 10th cycle)
65
+ subscription = s.sub.subscribe(
66
+ ["root/Control/dummyDouble"], "myGroup", frq_divider=10,
67
+ )
68
+ subscription.get()
69
+ subscription.notify(lambda result: print(result[0].value))
70
+ ```
71
+
72
+ Explicit-objects form — the original API, still supported:
73
+
74
+ ```python
75
+ types = motorcortex.MessageTypes()
76
+ tree = motorcortex.ParameterTree()
77
+
78
+ req, sub = motorcortex.connect(
79
+ "wss://192.168.2.100", types, tree,
80
+ certificate="mcx.cert.crt",
81
+ login="admin", password="admin",
82
+ )
83
+ try:
84
+ reply = req.getParameter("root/Control/dummyDouble").get()
85
+ print(reply.value)
86
+ finally:
87
+ sub.close()
88
+ req.close()
89
+ ```
90
+
91
+ The URL grammar accepts IPv4, hostnames, and IPv6 literals both with and without explicit ports — e.g. `wss://host`, `wss://host:5568:5567`, `wss://[::1]`, `wss://[fe80::1]:5568:5567`.
92
+
93
+ ## Documentation
94
+
95
+ - **[API reference — `docs/_index.md`](docs/_index.md)** — flat, method-by-method reference for every public class and function. Regenerated from the docstrings via `pydoc-markdown`.
96
+ - **[`ARCHITECTURE.md`](ARCHITECTURE.md)** — internals tour: module layout, connection lifecycle, subscribe frame format, parameter-tree cache, threading model, error contract, type conventions.
97
+ - **[`examples/README.md`](examples/README.md)** — runnable scripts (`quickstart.py`, `error_handling.py`) that demonstrate the canonical usage patterns.
98
+ - **[`CHANGELOG.md`](CHANGELOG.md)** — version history.
99
+
100
+ ## Repository layout
101
+
102
+ ```
103
+ motorcortex/ Python package
104
+ test/
105
+ unit/ Offline unit tests (no server)
106
+ integration/ Live tests against the vendored test_server
107
+ server/ Vendored C++ test_server (CMake project)
108
+ docs/ pydoc-markdown + stub generation scripts
109
+ benchmark/ Throughput scripts (not part of the test suite)
110
+ sandbox/ Ad-hoc repro scripts
111
+ ```
112
+
113
+ ## Testing
114
+
115
+ Unit tests run offline and require only the package itself:
116
+
117
+ ```bash
118
+ pip install -e .
119
+ pip install "coverage[toml]>=7.4"
120
+ python -m unittest discover -s test/unit -t .
121
+ ```
122
+
123
+ Integration tests spawn the vendored `test_server`. Build it once, then run the suite:
124
+
125
+ ```bash
126
+ cmake -S test/server -B test/server/build -DCMAKE_BUILD_TYPE=Release
127
+ cmake --build test/server/build
128
+
129
+ python -m unittest discover -s test/integration -t .
130
+ ```
131
+
132
+ Coverage (line + branch). `pyproject.toml` sets `parallel = true`, so each
133
+ `coverage run` writes a per-process data shard; use `coverage combine` to
134
+ merge them before `coverage report`:
135
+
136
+ ```bash
137
+ coverage erase
138
+ coverage run -m unittest discover -s test/unit -t .
139
+ coverage run -m unittest discover -s test/integration -t .
140
+ coverage combine
141
+ coverage report
142
+ ```
143
+
144
+ See [`test/README.md`](test/README.md) for the full testing walkthrough.
145
+
146
+ ## Regenerating the API reference
147
+
148
+ [`docs/_index.md`](docs/_index.md) is committed and should be refreshed
149
+ before each release so the rendered reference matches the code at
150
+ the tag. The regen is two commands — `pydoc-markdown` first, then
151
+ `format_api.sh` to wrap `>>>` examples as fenced Python blocks and
152
+ prepend the front matter:
153
+
154
+ ```bash
155
+ pip install pydoc-markdown
156
+ cd docs
157
+ pydoc-markdown pydoc-markdown.yml > _index.md
158
+ ./format_api.sh
159
+ ```
160
+
161
+ The hook-based one-shot version was dropped — it races with the
162
+ shell redirect and silently loses output. See `docs/readme.md` and
163
+ the comment in `docs/pydoc-markdown.yml` for the full rationale.
164
+
165
+ ## Release process
166
+
167
+ See [`PIPHOWTO.md`](PIPHOWTO.md) for PyPI release steps and [`CHANGELOG.md`](CHANGELOG.md) for version history.
168
+
169
+ ## License
170
+
171
+ MIT — see [`LICENSE`](LICENSE).
@@ -0,0 +1,146 @@
1
+ # motorcortex-python
2
+
3
+ [![pipeline status](https://git.vectioneer.com/pub/motorcortex-python/badges/master/pipeline.svg)](https://git.vectioneer.com/pub/motorcortex-python/-/commits/master)
4
+ [![coverage report](https://git.vectioneer.com/pub/motorcortex-python/badges/master/coverage.svg?job=coverage)](https://git.vectioneer.com/pub/motorcortex-python/-/commits/master)
5
+ [![PyPI version](https://img.shields.io/pypi/v/motorcortex-python.svg)](https://pypi.org/project/motorcortex-python/)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/motorcortex-python.svg)](https://pypi.org/project/motorcortex-python/)
7
+ [![License: MIT](https://img.shields.io/pypi/l/motorcortex-python.svg)](LICENSE)
8
+
9
+ Python bindings for the [Motorcortex](https://www.motorcortex.io) real-time control engine. Connect to a Motorcortex server, read and write parameters, and stream live telemetry over a single TLS-secured websocket.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install motorcortex-python
15
+ ```
16
+
17
+ Requires Python ≥ 3.10. Runtime dependencies (`pynng`, `protobuf`) are installed automatically.
18
+
19
+ ## Quick start
20
+
21
+ Recommended — `Session` context manager (close is automatic, even on exceptions):
22
+
23
+ ```python
24
+ import motorcortex
25
+
26
+ with motorcortex.Session(
27
+ "wss://192.168.2.100",
28
+ certificate="mcx.cert.crt",
29
+ login="admin", password="admin",
30
+ timeout_ms=1000,
31
+ ) as s:
32
+ # Read a single parameter
33
+ reply = s.req.getParameter("root/Control/dummyDouble").get()
34
+ print(reply.value)
35
+
36
+ # Write a parameter
37
+ s.req.setParameter("root/Control/dummyDouble", 3.14).get()
38
+
39
+ # Subscribe to a streamed update (every 10th cycle)
40
+ subscription = s.sub.subscribe(
41
+ ["root/Control/dummyDouble"], "myGroup", frq_divider=10,
42
+ )
43
+ subscription.get()
44
+ subscription.notify(lambda result: print(result[0].value))
45
+ ```
46
+
47
+ Explicit-objects form — the original API, still supported:
48
+
49
+ ```python
50
+ types = motorcortex.MessageTypes()
51
+ tree = motorcortex.ParameterTree()
52
+
53
+ req, sub = motorcortex.connect(
54
+ "wss://192.168.2.100", types, tree,
55
+ certificate="mcx.cert.crt",
56
+ login="admin", password="admin",
57
+ )
58
+ try:
59
+ reply = req.getParameter("root/Control/dummyDouble").get()
60
+ print(reply.value)
61
+ finally:
62
+ sub.close()
63
+ req.close()
64
+ ```
65
+
66
+ The URL grammar accepts IPv4, hostnames, and IPv6 literals both with and without explicit ports — e.g. `wss://host`, `wss://host:5568:5567`, `wss://[::1]`, `wss://[fe80::1]:5568:5567`.
67
+
68
+ ## Documentation
69
+
70
+ - **[API reference — `docs/_index.md`](docs/_index.md)** — flat, method-by-method reference for every public class and function. Regenerated from the docstrings via `pydoc-markdown`.
71
+ - **[`ARCHITECTURE.md`](ARCHITECTURE.md)** — internals tour: module layout, connection lifecycle, subscribe frame format, parameter-tree cache, threading model, error contract, type conventions.
72
+ - **[`examples/README.md`](examples/README.md)** — runnable scripts (`quickstart.py`, `error_handling.py`) that demonstrate the canonical usage patterns.
73
+ - **[`CHANGELOG.md`](CHANGELOG.md)** — version history.
74
+
75
+ ## Repository layout
76
+
77
+ ```
78
+ motorcortex/ Python package
79
+ test/
80
+ unit/ Offline unit tests (no server)
81
+ integration/ Live tests against the vendored test_server
82
+ server/ Vendored C++ test_server (CMake project)
83
+ docs/ pydoc-markdown + stub generation scripts
84
+ benchmark/ Throughput scripts (not part of the test suite)
85
+ sandbox/ Ad-hoc repro scripts
86
+ ```
87
+
88
+ ## Testing
89
+
90
+ Unit tests run offline and require only the package itself:
91
+
92
+ ```bash
93
+ pip install -e .
94
+ pip install "coverage[toml]>=7.4"
95
+ python -m unittest discover -s test/unit -t .
96
+ ```
97
+
98
+ Integration tests spawn the vendored `test_server`. Build it once, then run the suite:
99
+
100
+ ```bash
101
+ cmake -S test/server -B test/server/build -DCMAKE_BUILD_TYPE=Release
102
+ cmake --build test/server/build
103
+
104
+ python -m unittest discover -s test/integration -t .
105
+ ```
106
+
107
+ Coverage (line + branch). `pyproject.toml` sets `parallel = true`, so each
108
+ `coverage run` writes a per-process data shard; use `coverage combine` to
109
+ merge them before `coverage report`:
110
+
111
+ ```bash
112
+ coverage erase
113
+ coverage run -m unittest discover -s test/unit -t .
114
+ coverage run -m unittest discover -s test/integration -t .
115
+ coverage combine
116
+ coverage report
117
+ ```
118
+
119
+ See [`test/README.md`](test/README.md) for the full testing walkthrough.
120
+
121
+ ## Regenerating the API reference
122
+
123
+ [`docs/_index.md`](docs/_index.md) is committed and should be refreshed
124
+ before each release so the rendered reference matches the code at
125
+ the tag. The regen is two commands — `pydoc-markdown` first, then
126
+ `format_api.sh` to wrap `>>>` examples as fenced Python blocks and
127
+ prepend the front matter:
128
+
129
+ ```bash
130
+ pip install pydoc-markdown
131
+ cd docs
132
+ pydoc-markdown pydoc-markdown.yml > _index.md
133
+ ./format_api.sh
134
+ ```
135
+
136
+ The hook-based one-shot version was dropped — it races with the
137
+ shell redirect and silently loses output. See `docs/readme.md` and
138
+ the comment in `docs/pydoc-markdown.yml` for the full rationale.
139
+
140
+ ## Release process
141
+
142
+ See [`PIPHOWTO.md`](PIPHOWTO.md) for PyPI release steps and [`CHANGELOG.md`](CHANGELOG.md) for version history.
143
+
144
+ ## License
145
+
146
+ MIT — see [`LICENSE`](LICENSE).
@@ -2,7 +2,7 @@
2
2
 
3
3
  #
4
4
  # Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
5
- # All rights reserved. Copyright (c) 2016 - 2025 VECTIONEER.
5
+ # All rights reserved. Copyright (c) 2016 - 2026 VECTIONEER.
6
6
  #
7
7
 
8
8
  """
@@ -14,6 +14,9 @@ Provides high-level APIs for communication, login, and data exchange using proto
14
14
  See documentation for usage examples.
15
15
  """
16
16
 
17
+ from enum import IntEnum as _IntEnum
18
+ from typing import Any
19
+
17
20
  from motorcortex.version import __version__
18
21
  from motorcortex.parameter_tree import ParameterTree
19
22
  from motorcortex.message_types import MessageTypes
@@ -21,11 +24,105 @@ from motorcortex.request import Request, ConnectionState
21
24
  from motorcortex.reply import Reply
22
25
  from motorcortex.subscribe import Subscribe
23
26
  from motorcortex.subscription import Subscription
24
- from motorcortex.timespec import Timespec, compare_timespec, timespec_to_sec, timespec_to_msec, timespec_to_usec, \
25
- timespec_to_nsec
26
- from motorcortex.setup_logger import logger
27
- from motorcortex.state_callback_handler import StateCallbackHandler
27
+ from motorcortex.timespec import (
28
+ Timespec,
29
+ compare_timespec,
30
+ timespec_to_sec,
31
+ timespec_to_msec,
32
+ timespec_to_usec,
33
+ timespec_to_nsec,
34
+ )
28
35
  from motorcortex.init_threads import init_nng_threads
36
+ from motorcortex.session import Session
37
+ from motorcortex.exceptions import (
38
+ McxError,
39
+ McxConnectionError,
40
+ McxLoginError,
41
+ McxTimeout,
42
+ )
43
+ from motorcortex.motorcortex_pb2 import StatusCode as _PbStatusCode
44
+ from motorcortex.setup_logger import logger # not re-exported — ``__all__`` gates package surface
45
+
46
+ # Build an ``IntEnum`` facade over the protobuf ``StatusCode`` enum so
47
+ # callers can write ``motorcortex.StatusCode.OK`` without reaching into
48
+ # the generated protobuf module. Subclassing ``int`` means comparisons
49
+ # like ``reply.status == StatusCode.OK`` work against the raw ints that
50
+ # come back on the wire.
51
+ StatusCode = _IntEnum(
52
+ "StatusCode",
53
+ {v.name: v.number for v in _PbStatusCode.DESCRIPTOR.values},
54
+ )
55
+ StatusCode.__doc__ = (
56
+ "Motorcortex response status codes. Members mirror the ``StatusCode`` "
57
+ "enum in the wire protocol. Use ``motorcortex.StatusCode.OK`` (or the "
58
+ "flat re-exports such as ``motorcortex.OK``) to compare against "
59
+ "``reply.status``."
60
+ )
61
+
62
+ # Flat re-exports for the conventional ``motorcortex.OK`` idiom. Values
63
+ # are members of :class:`StatusCode` and therefore also ``int``.
64
+ OK = StatusCode.OK
65
+ READ_ONLY_MODE = StatusCode.READ_ONLY_MODE
66
+ FAILED = StatusCode.FAILED
67
+ FAILED_TO_DECODE = StatusCode.FAILED_TO_DECODE
68
+ SUB_LIST_IS_FULL = StatusCode.SUB_LIST_IS_FULL
69
+ WRONG_PARAMETER_PATH = StatusCode.WRONG_PARAMETER_PATH
70
+ FAILED_TO_SET_REQUESTED_FRQ = StatusCode.FAILED_TO_SET_REQUESTED_FRQ
71
+ FAILED_TO_OPEN_FILE = StatusCode.FAILED_TO_OPEN_FILE
72
+ GROUP_LIST_IS_FULL = StatusCode.GROUP_LIST_IS_FULL
73
+ WRONG_PASSWORD = StatusCode.WRONG_PASSWORD
74
+ USER_NOT_LOGGED_IN = StatusCode.USER_NOT_LOGGED_IN
75
+ PERMISSION_DENIED = StatusCode.PERMISSION_DENIED
76
+
77
+ # ``__all__`` is the 1.0 public API surface. Anything not listed here —
78
+ # including module-level helpers defined below (``parseUrl``,
79
+ # ``makeUrl``, ``statusToStr``) and submodule imports like
80
+ # ``motorcortex.setup_logger`` — is implicitly private and may change
81
+ # between minor releases. For logging, do
82
+ # ``logging.getLogger("mcx")`` instead of reaching into the package.
83
+ __all__ = [
84
+ "__version__",
85
+ # Entry points
86
+ "connect",
87
+ "Session",
88
+ # Connection / protocol classes
89
+ "Request",
90
+ "Subscribe",
91
+ "Subscription",
92
+ "Reply",
93
+ "ConnectionState",
94
+ # Protobuf / parameters
95
+ "MessageTypes",
96
+ "ParameterTree",
97
+ # Time
98
+ "Timespec",
99
+ "compare_timespec",
100
+ "timespec_to_sec",
101
+ "timespec_to_msec",
102
+ "timespec_to_usec",
103
+ "timespec_to_nsec",
104
+ # Exceptions
105
+ "McxError",
106
+ "McxConnectionError",
107
+ "McxLoginError",
108
+ "McxTimeout",
109
+ # Status codes
110
+ "StatusCode",
111
+ "OK",
112
+ "READ_ONLY_MODE",
113
+ "FAILED",
114
+ "FAILED_TO_DECODE",
115
+ "SUB_LIST_IS_FULL",
116
+ "WRONG_PARAMETER_PATH",
117
+ "FAILED_TO_SET_REQUESTED_FRQ",
118
+ "FAILED_TO_OPEN_FILE",
119
+ "GROUP_LIST_IS_FULL",
120
+ "WRONG_PASSWORD",
121
+ "USER_NOT_LOGGED_IN",
122
+ "PERMISSION_DENIED",
123
+ # Tuning
124
+ "init_nng_threads",
125
+ ]
29
126
 
30
127
  init_nng_threads()
31
128
 
@@ -46,9 +143,20 @@ def parseUrl(url: str) -> tuple[str, str, int | None, int | None]:
46
143
 
47
144
  If the URL does not contain ports, default endpoints '/mcx_req' and '/mcx_sub' are appended.
48
145
  """
146
+ # IPv6 host literals embed colons (``wss://[::1]``), so the rfind-based
147
+ # port scan has to start *after* the closing bracket of the host
148
+ # literal — otherwise it walks into the address bytes and either
149
+ # mis-parses the ports or (in the no-ports case) tries to int() an
150
+ # empty slice. For IPv4 / hostname URLs there is no bracket, so
151
+ # ``port_start`` stays at 0 and behavior matches the pre-fix code.
152
+ host_end = url.rfind(']')
153
+ port_start = host_end + 1 if host_end != -1 else 0
154
+
49
155
  end = url.rfind(':')
50
- start = url.rfind(':', 0, end)
51
- if end == -1 or start == -1:
156
+ if end < port_start:
157
+ return url + '/mcx_req', url + '/mcx_sub', None, None
158
+ start = url.rfind(':', port_start, end)
159
+ if start == -1:
52
160
  return url + '/mcx_req', url + '/mcx_sub', None, None
53
161
  req_port = int(url[start + 1:end])
54
162
  sub_port = int(url[end + 1:])
@@ -75,10 +183,10 @@ def makeUrl(address: str, port: int | None) -> str:
75
183
 
76
184
  def connect(
77
185
  url: str,
78
- motorcortex_types: object,
186
+ motorcortex_types: "MessageTypes",
79
187
  param_tree: "ParameterTree",
80
188
  reconnect: bool = True,
81
- **kwargs
189
+ **kwargs: Any,
82
190
  ) -> tuple["Request", "Subscribe"]:
83
191
  """
84
192
  Establishes connections to Motorcortex request and subscribe endpoints, performs login, and loads the parameter tree.
@@ -152,26 +260,52 @@ def connect(
152
260
  req_address, sub_address, req_port, sub_port = parseUrl(url)
153
261
  # Open request connection
154
262
  req = Request(motorcortex_types, param_tree, kwargs.get("req_number_of_threads", 2))
155
- kwargs_copy = kwargs.copy()
156
- kwargs_copy.update(state_update=None)
157
- if not req.connect(makeUrl(req_address, req_port), **kwargs_copy).get():
158
- raise RuntimeError("Failed to establish request connection: {}:{}".format(req_address, req_port))
159
- # Open subscribe connection
160
- sub = Subscribe(req, motorcortex_types, kwargs.get("sub_number_of_threads", 2))
161
- if not sub.connect(makeUrl(sub_address, sub_port), **kwargs).get():
162
- raise RuntimeError("Failed to establish subscribe connection: {}:{}".format(sub_address, sub_port))
163
- # Login
164
- login_reply = req.login(kwargs['login'], kwargs['password'])
165
- login_reply_msg = login_reply.get()
166
-
167
- motorcortex_msg = motorcortex_types.motorcortex()
168
- if not login_reply_msg.status == motorcortex_msg.OK:
169
- raise RuntimeError("Login failed, status: {}".format(login_reply_msg.status))
170
-
171
- # Requesting a parameter tree
172
- param_tree_reply = req.getParameterTree()
173
- tree = param_tree_reply.get()
174
- param_tree.load(tree)
263
+ sub = None
264
+ # Wrap every post-construction failure point so half-opened sockets
265
+ # get closed; otherwise the Subscribe receive thread stays blocked in
266
+ # recv() forever and pins the Python interpreter on exit (an atexit
267
+ # ``_python_exit`` handler then joins the worker indefinitely).
268
+ try:
269
+ kwargs_copy = kwargs.copy()
270
+ kwargs_copy.update(state_update=None)
271
+ if not req.connect(makeUrl(req_address, req_port), **kwargs_copy).get():
272
+ raise McxConnectionError(
273
+ "Failed to establish request connection: {}:{}".format(req_address, req_port)
274
+ )
275
+ # Open subscribe connection
276
+ sub = Subscribe(req, motorcortex_types, kwargs.get("sub_number_of_threads", 2))
277
+ if not sub.connect(makeUrl(sub_address, sub_port), **kwargs).get():
278
+ raise McxConnectionError(
279
+ "Failed to establish subscribe connection: {}:{}".format(sub_address, sub_port)
280
+ )
281
+ # Login. With ``Login: disable`` servers the credentials can legitimately
282
+ # be omitted — default to empty strings so the helper does not raise
283
+ # ``KeyError`` on valid callers.
284
+ login_reply = req.login(kwargs.get('login', ''), kwargs.get('password', ''))
285
+ login_reply_msg = login_reply.get()
286
+
287
+ motorcortex_msg = motorcortex_types.motorcortex()
288
+ if not login_reply_msg.status == motorcortex_msg.OK:
289
+ raise McxLoginError(
290
+ "Login failed, status: {}".format(login_reply_msg.status),
291
+ status=login_reply_msg.status,
292
+ )
293
+
294
+ # Requesting a parameter tree
295
+ param_tree_reply = req.getParameterTree()
296
+ tree = param_tree_reply.get()
297
+ param_tree.load(tree)
298
+ except Exception:
299
+ if sub is not None:
300
+ try:
301
+ sub.close()
302
+ except Exception:
303
+ pass
304
+ try:
305
+ req.close()
306
+ except Exception:
307
+ pass
308
+ raise
175
309
 
176
310
  # Start session token refresh
177
311
  req._startTokenRefresh(token_interval_sec)
@@ -181,7 +315,7 @@ def connect(
181
315
  return req, sub
182
316
 
183
317
 
184
- def statusToStr(motorcortex_msg: object, code: int) -> str:
318
+ def statusToStr(motorcortex_msg: Any, code: int) -> str:
185
319
  """Converts status codes to a readable message.
186
320
 
187
321
  Args:
@@ -0,0 +1,58 @@
1
+ #
2
+ # Developer: Alexey Zakharov (alexey.zakharov@vectioneer.com)
3
+ # All rights reserved. Copyright (c) 2026 VECTIONEER.
4
+ #
5
+
6
+ """Connection-state enum + pure state-transition helpers.
7
+
8
+ ``ConnectionState`` was historically defined in ``motorcortex.request`` and
9
+ is still re-exported from there (and from ``motorcortex``) for backward
10
+ compatibility. It lives here so pure helpers can depend on it without
11
+ dragging the whole ``request`` module — which would cause a circular
12
+ import.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from enum import Enum
18
+
19
+
20
+ class ConnectionState(Enum):
21
+ """Enumeration of connection states.
22
+
23
+ - CONNECTING: Connection is being established.
24
+ - CONNECTION_OK: Connection is successfully established.
25
+ - CONNECTION_LOST: Connection was lost.
26
+ - CONNECTION_FAILED: Connection attempt failed.
27
+ - DISCONNECTING: Connection is being closed.
28
+ - DISCONNECTED: Connection is closed.
29
+ """
30
+ CONNECTING = 0
31
+ CONNECTION_OK = 1
32
+ CONNECTION_LOST = 2
33
+ CONNECTION_FAILED = 3
34
+ DISCONNECTING = 4
35
+ DISCONNECTED = 5
36
+
37
+
38
+ def next_state_after_pipe_remove(current: ConnectionState) -> ConnectionState:
39
+ """Map the current state to the one we enter when the remote pipe goes away.
40
+
41
+ nng fires its post-remove callback when a peer socket is torn down, but
42
+ the callback itself can't tell *why*: we might have called
43
+ ``close()`` (clean shutdown) or the remote might have died (lost /
44
+ failed). The only disambiguator is the state we were already in.
45
+
46
+ Transitions:
47
+ DISCONNECTING → DISCONNECTED (clean close we initiated)
48
+ CONNECTING → CONNECTION_FAILED (never finished handshake)
49
+ CONNECTION_OK → CONNECTION_LOST (remote went away mid-session)
50
+ anything else → unchanged (idempotent / re-entry safe)
51
+ """
52
+ if current == ConnectionState.DISCONNECTING:
53
+ return ConnectionState.DISCONNECTED
54
+ if current == ConnectionState.CONNECTING:
55
+ return ConnectionState.CONNECTION_FAILED
56
+ if current == ConnectionState.CONNECTION_OK:
57
+ return ConnectionState.CONNECTION_LOST
58
+ return current