ironflock 1.3.8__py3-none-any.whl → 1.3.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ironflock/CrossbarConnection.py +149 -24
- ironflock/__init__.py +6 -1
- {ironflock-1.3.8.dist-info → ironflock-1.3.9.dist-info}/METADATA +39 -11
- ironflock-1.3.9.dist-info/RECORD +8 -0
- {ironflock-1.3.8.dist-info → ironflock-1.3.9.dist-info}/WHEEL +1 -1
- ironflock-1.3.8.dist-info/RECORD +0 -8
- {ironflock-1.3.8.dist-info → ironflock-1.3.9.dist-info}/licenses/LICENSE +0 -0
ironflock/CrossbarConnection.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import time
|
|
3
2
|
import os
|
|
4
3
|
from typing import Optional, List, Dict, Any, Callable
|
|
5
4
|
from autobahn.asyncio.wamp import ApplicationSession
|
|
@@ -31,7 +30,7 @@ def getSerialNumber(serial_number: str = None) -> str:
|
|
|
31
30
|
STUDIO_DEV_WS_URI = "wss://cbw.ironflock.dev/ws-ua-usr"
|
|
32
31
|
STUDIO_WS_URI_OLD = "wss://cbw.record-evolution.com/ws-ua-usr"
|
|
33
32
|
STUDIO_WS_URI = "wss://cbw.ironflock.com/ws-ua-usr"
|
|
34
|
-
LOCALHOST_WS_URI = "ws://localhost:
|
|
33
|
+
LOCALHOST_WS_URI = "ws://localhost:18080/ws-ua-usr"
|
|
35
34
|
|
|
36
35
|
socketURIMap = {
|
|
37
36
|
"https://studio.ironflock.dev": STUDIO_DEV_WS_URI,
|
|
@@ -56,9 +55,13 @@ class CrossbarConnection:
|
|
|
56
55
|
self.connection_drop_wait_time: int = 6000 # milliseconds
|
|
57
56
|
self.realm: Optional[str] = None
|
|
58
57
|
self.serial_number: Optional[str] = None
|
|
59
|
-
self.
|
|
58
|
+
self._connection_event: Optional[asyncio.Event] = None
|
|
60
59
|
self._is_connected = False
|
|
61
60
|
self._session_wait_timeout = 6.0 # seconds
|
|
61
|
+
self._first_connection_timeout = 30.0 # seconds - timeout for initial connection
|
|
62
|
+
self._max_connection_retries = 5 # maximum retries for first connection
|
|
63
|
+
self._connection_error: Optional[Exception] = None
|
|
64
|
+
self._component_task: Optional[asyncio.Task] = None
|
|
62
65
|
|
|
63
66
|
@staticmethod
|
|
64
67
|
def getWebSocketURI():
|
|
@@ -136,18 +139,21 @@ class CrossbarConnection:
|
|
|
136
139
|
self.component.on('leave', self._on_close)
|
|
137
140
|
self.component.on('disconnect', self._on_disconnect)
|
|
138
141
|
|
|
139
|
-
# Create
|
|
140
|
-
self.
|
|
142
|
+
# Create event for connection signaling (can be reset unlike Future)
|
|
143
|
+
self._connection_event = asyncio.Event()
|
|
144
|
+
self._connection_error = None
|
|
141
145
|
|
|
142
146
|
async def _on_open(self, session: ApplicationSession, details):
|
|
143
147
|
"""Handle session open event"""
|
|
144
148
|
self.session = session
|
|
145
149
|
self._is_connected = True
|
|
150
|
+
self._connection_error = None
|
|
146
151
|
await self._resubscribe_all()
|
|
147
152
|
print(f"Connection to IronFlock app realm '{session._realm}' established")
|
|
148
153
|
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
# Signal that connection is established
|
|
155
|
+
if self._connection_event:
|
|
156
|
+
self._connection_event.set()
|
|
151
157
|
|
|
152
158
|
async def _on_close(self, session: ApplicationSession, details):
|
|
153
159
|
"""Handle session close event"""
|
|
@@ -155,25 +161,48 @@ class CrossbarConnection:
|
|
|
155
161
|
self._is_connected = False
|
|
156
162
|
print(f"Connection to IronFlock app realm {self.realm} closed: {details.reason}")
|
|
157
163
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
# Store error for connection waiters
|
|
165
|
+
self._connection_error = Exception(f"Connection closed: {details.reason}")
|
|
166
|
+
|
|
167
|
+
# Reset event so next connection attempt can wait
|
|
168
|
+
if self._connection_event:
|
|
169
|
+
self._connection_event.clear()
|
|
163
170
|
|
|
164
171
|
async def _on_disconnect(self, session: ApplicationSession, was_clean: bool):
|
|
165
172
|
"""Handle disconnect event"""
|
|
166
173
|
self.session = None
|
|
167
174
|
self._is_connected = False
|
|
168
175
|
print(f"Disconnected from IronFlock app realm {self.realm}, clean: {was_clean}")
|
|
176
|
+
|
|
177
|
+
# Reset event so next connection attempt can wait
|
|
178
|
+
if self._connection_event:
|
|
179
|
+
self._connection_event.clear()
|
|
169
180
|
|
|
170
181
|
async def _session_wait(self) -> None:
|
|
171
182
|
"""Wait for session to be available"""
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
183
|
+
if self.session and self._is_connected:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# If we have a connection event, wait on it with timeout
|
|
187
|
+
if self._connection_event:
|
|
188
|
+
try:
|
|
189
|
+
await asyncio.wait_for(
|
|
190
|
+
self._connection_event.wait(),
|
|
191
|
+
timeout=self._session_wait_timeout
|
|
192
|
+
)
|
|
193
|
+
except asyncio.TimeoutError:
|
|
175
194
|
raise TimeoutError('Timeout waiting for session')
|
|
176
|
-
|
|
195
|
+
else:
|
|
196
|
+
# No connection event means configure() wasn't called or connection not started
|
|
197
|
+
# Wait with timeout using sleep-based approach
|
|
198
|
+
await asyncio.sleep(self._session_wait_timeout)
|
|
199
|
+
raise TimeoutError('Timeout waiting for session')
|
|
200
|
+
|
|
201
|
+
# Double-check we have a valid session
|
|
202
|
+
if not self.session or not self._is_connected:
|
|
203
|
+
if self._connection_error:
|
|
204
|
+
raise self._connection_error
|
|
205
|
+
raise RuntimeError('Session not available')
|
|
177
206
|
|
|
178
207
|
@property
|
|
179
208
|
def is_open(self) -> bool:
|
|
@@ -181,18 +210,112 @@ class CrossbarConnection:
|
|
|
181
210
|
return self._is_connected and self.session is not None
|
|
182
211
|
|
|
183
212
|
async def start(self) -> None:
|
|
184
|
-
"""Start the connection"""
|
|
213
|
+
"""Start the connection with timeout and retry logic"""
|
|
185
214
|
if not self.component:
|
|
186
215
|
raise ValueError("Must call configure() before start()")
|
|
187
216
|
|
|
188
217
|
print(f'Starting connection for IronFlock app realm {self.realm}')
|
|
189
218
|
|
|
190
|
-
|
|
191
|
-
self.component.start()
|
|
219
|
+
last_error: Optional[Exception] = None
|
|
192
220
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
221
|
+
for attempt in range(1, self._max_connection_retries + 1):
|
|
222
|
+
try:
|
|
223
|
+
# Reset connection state for this attempt
|
|
224
|
+
self._connection_error = None
|
|
225
|
+
if self._connection_event:
|
|
226
|
+
self._connection_event.clear()
|
|
227
|
+
|
|
228
|
+
# Stop any existing component task
|
|
229
|
+
if self._component_task and not self._component_task.done():
|
|
230
|
+
self._component_task.cancel()
|
|
231
|
+
try:
|
|
232
|
+
await self._component_task
|
|
233
|
+
except asyncio.CancelledError:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
# Start the component in a background task
|
|
237
|
+
self._component_task = asyncio.create_task(self._run_component())
|
|
238
|
+
|
|
239
|
+
# Wait for connection with timeout
|
|
240
|
+
if self._connection_event:
|
|
241
|
+
try:
|
|
242
|
+
await asyncio.wait_for(
|
|
243
|
+
self._connection_event.wait(),
|
|
244
|
+
timeout=self._first_connection_timeout
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Check if we actually connected or got an error
|
|
248
|
+
if self._is_connected and self.session:
|
|
249
|
+
print(f"Successfully connected on attempt {attempt}")
|
|
250
|
+
return
|
|
251
|
+
elif self._connection_error:
|
|
252
|
+
raise self._connection_error
|
|
253
|
+
|
|
254
|
+
except asyncio.TimeoutError:
|
|
255
|
+
print(f"Connection attempt {attempt}/{self._max_connection_retries} timed out after {self._first_connection_timeout}s")
|
|
256
|
+
last_error = TimeoutError(f"Connection timed out after {self._first_connection_timeout}s")
|
|
257
|
+
|
|
258
|
+
# Stop the component before retrying
|
|
259
|
+
await self._stop_component()
|
|
260
|
+
|
|
261
|
+
if attempt < self._max_connection_retries:
|
|
262
|
+
retry_delay = min(2 ** attempt, 30) # Exponential backoff, max 30s
|
|
263
|
+
print(f"Retrying in {retry_delay}s...")
|
|
264
|
+
await asyncio.sleep(retry_delay)
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
last_error = e
|
|
269
|
+
print(f"Connection attempt {attempt}/{self._max_connection_retries} failed: {e}")
|
|
270
|
+
|
|
271
|
+
# Stop the component before retrying
|
|
272
|
+
await self._stop_component()
|
|
273
|
+
|
|
274
|
+
if attempt < self._max_connection_retries:
|
|
275
|
+
retry_delay = min(2 ** attempt, 30) # Exponential backoff, max 30s
|
|
276
|
+
print(f"Retrying in {retry_delay}s...")
|
|
277
|
+
await asyncio.sleep(retry_delay)
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
# All retries exhausted
|
|
281
|
+
raise ConnectionError(
|
|
282
|
+
f"Failed to connect after {self._max_connection_retries} attempts. Last error: {last_error}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async def _run_component(self) -> None:
|
|
286
|
+
"""Run the component in the background"""
|
|
287
|
+
try:
|
|
288
|
+
await self.component.start()
|
|
289
|
+
except asyncio.CancelledError:
|
|
290
|
+
pass
|
|
291
|
+
except Exception as e:
|
|
292
|
+
print(f"Component error: {e}")
|
|
293
|
+
self._connection_error = e
|
|
294
|
+
if self._connection_event:
|
|
295
|
+
self._connection_event.set() # Signal to unblock waiters
|
|
296
|
+
|
|
297
|
+
async def _stop_component(self) -> None:
|
|
298
|
+
"""Stop the component and cleanup"""
|
|
299
|
+
try:
|
|
300
|
+
if self._component_task and not self._component_task.done():
|
|
301
|
+
self._component_task.cancel()
|
|
302
|
+
try:
|
|
303
|
+
await asyncio.wait_for(self._component_task, timeout=5.0)
|
|
304
|
+
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
if self.component:
|
|
308
|
+
try:
|
|
309
|
+
await asyncio.wait_for(self.component.stop(), timeout=5.0)
|
|
310
|
+
except asyncio.TimeoutError:
|
|
311
|
+
print("Component stop timed out")
|
|
312
|
+
except Exception as e:
|
|
313
|
+
print(f"Error stopping component: {e}")
|
|
314
|
+
except Exception as e:
|
|
315
|
+
print(f"Error during component cleanup: {e}")
|
|
316
|
+
finally:
|
|
317
|
+
self._is_connected = False
|
|
318
|
+
self.session = None
|
|
196
319
|
|
|
197
320
|
async def stop(self) -> None:
|
|
198
321
|
"""Stop the connection"""
|
|
@@ -294,7 +417,7 @@ class CrossbarConnection:
|
|
|
294
417
|
self,
|
|
295
418
|
topic: str,
|
|
296
419
|
handler: Optional[Callable] = None
|
|
297
|
-
) ->
|
|
420
|
+
) -> bool:
|
|
298
421
|
"""Unsubscribe a specific function from a topic"""
|
|
299
422
|
await self._session_wait()
|
|
300
423
|
|
|
@@ -313,7 +436,9 @@ class CrossbarConnection:
|
|
|
313
436
|
await self.unsubscribe(subscription)
|
|
314
437
|
except Exception as e:
|
|
315
438
|
print(f"Failed to unsubscribe from {topic}: {e}")
|
|
316
|
-
|
|
439
|
+
return False
|
|
440
|
+
return True
|
|
441
|
+
|
|
317
442
|
async def unsubscribe_topic(self, topic: str) -> bool:
|
|
318
443
|
"""Unsubscribe all handlers from a topic"""
|
|
319
444
|
matching_subs = [
|
ironflock/__init__.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
2
|
+
|
|
1
3
|
from ironflock.ironflock import IronFlock
|
|
2
4
|
from ironflock.CrossbarConnection import CrossbarConnection, Stage
|
|
3
5
|
|
|
@@ -7,4 +9,7 @@ __all__ = [
|
|
|
7
9
|
"Stage"
|
|
8
10
|
]
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("ironflock")
|
|
14
|
+
except PackageNotFoundError:
|
|
15
|
+
__version__ = "0.0.0" # fallback for development
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ironflock
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.9
|
|
4
4
|
Summary: IronFlock Python SDK for connecting to the IronFlock Platform
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -11,11 +11,12 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Provides-Extra: dev
|
|
13
13
|
Provides-Extra: docs
|
|
14
|
-
Requires-Dist: autobahn[asyncio,serialization] (==25.
|
|
14
|
+
Requires-Dist: autobahn[asyncio,serialization] (==25.12.2)
|
|
15
15
|
Requires-Dist: mypy ; extra == "dev"
|
|
16
16
|
Requires-Dist: pydantic (>=2.0.0)
|
|
17
17
|
Requires-Dist: pydantic ; extra == "dev"
|
|
18
18
|
Requires-Dist: pytest ; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio ; extra == "dev"
|
|
19
20
|
Requires-Dist: ruff ; extra == "dev"
|
|
20
21
|
Requires-Dist: sphinx (>=8.2.3) ; extra == "docs"
|
|
21
22
|
Requires-Dist: sphinx-autodoc-typehints (>=2.0) ; extra == "docs"
|
|
@@ -90,42 +91,70 @@ the [examples](https://github.com/RecordEvolution/ironflock-py/tree/main/example
|
|
|
90
91
|
|
|
91
92
|
## Development
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
This project uses [uv](https://docs.astral.sh/uv/) for dependency management and building.
|
|
95
|
+
|
|
96
|
+
Install uv if you don't have it:
|
|
97
|
+
|
|
98
|
+
```shell
|
|
99
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Install dependencies (including dev dependencies):
|
|
103
|
+
|
|
104
|
+
```shell
|
|
105
|
+
uv sync --extra dev
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Run tests:
|
|
94
109
|
|
|
95
110
|
```shell
|
|
96
|
-
|
|
111
|
+
just test-unit # Run unit tests only
|
|
112
|
+
just test # Run all tests
|
|
113
|
+
just test-docker # Run integration tests with Docker
|
|
97
114
|
```
|
|
98
115
|
|
|
99
|
-
Make sure your pypi API tokens are stored in your `~/.pypirc`.
|
|
100
116
|
Build and publish a new pypi package:
|
|
101
117
|
|
|
102
118
|
```shell
|
|
103
119
|
just publish
|
|
104
120
|
```
|
|
105
121
|
|
|
106
|
-
|
|
122
|
+
Or manually:
|
|
107
123
|
|
|
108
124
|
```shell
|
|
109
125
|
# Clean previous builds
|
|
110
|
-
rm -rf
|
|
126
|
+
rm -rf dist
|
|
111
127
|
|
|
112
128
|
# Build the package
|
|
113
|
-
|
|
129
|
+
uv build
|
|
114
130
|
|
|
115
131
|
# Upload to PyPI
|
|
116
|
-
|
|
132
|
+
uv publish
|
|
117
133
|
```
|
|
118
134
|
|
|
119
135
|
Check the package at https://pypi.org/project/ironflock/.
|
|
120
136
|
|
|
121
137
|
## Test Deployment
|
|
122
138
|
|
|
123
|
-
To test the package before deploying to
|
|
139
|
+
To test the package before deploying to PyPI you can use test.pypi.
|
|
124
140
|
|
|
125
141
|
```shell
|
|
126
142
|
just clean build publish-test
|
|
127
143
|
```
|
|
128
144
|
|
|
145
|
+
Or manually:
|
|
146
|
+
|
|
147
|
+
```shell
|
|
148
|
+
uv build
|
|
149
|
+
uv publish --publish-url https://test.pypi.org/legacy/
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Once the package is published you can install it from TestPyPI:
|
|
153
|
+
|
|
154
|
+
```shell
|
|
155
|
+
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ironflock
|
|
156
|
+
```
|
|
157
|
+
|
|
129
158
|
Once the package is published you can use it in other code by putting
|
|
130
159
|
these lines at the top of the requirements.txt
|
|
131
160
|
|
|
@@ -134,4 +163,3 @@ these lines at the top of the requirements.txt
|
|
|
134
163
|
--extra-index-url https://pypi.org/simple/
|
|
135
164
|
```
|
|
136
165
|
|
|
137
|
-
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ironflock/CrossbarConnection.py,sha256=hDihh2RQYTbQLF4WD0WvSxpEK9RRcq7Fu-AhlavdM3s,19816
|
|
2
|
+
ironflock/__init__.py,sha256=yzf44H-czcGrOft1mVVnsGIPpn6DgQ0feTD6syHOf2k,370
|
|
3
|
+
ironflock/ironflock.py,sha256=sLP0Mfp3bgtyE0Qv7LTUDOY8YlRDM0PxJG8eZplluoU,19536
|
|
4
|
+
ironflock/types.py,sha256=yync91abyrI1fqbmczW5My7Skx7-8soENGHK2JfaEz0,11962
|
|
5
|
+
ironflock-1.3.9.dist-info/METADATA,sha256=1gbys7ut1kLmiJR8XvdB9XwtlUyaIJcSWvcoial6Xkc,4335
|
|
6
|
+
ironflock-1.3.9.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
7
|
+
ironflock-1.3.9.dist-info/licenses/LICENSE,sha256=GpUKjPB381nmkbBIdX74vxXhsNZaNpngTOciss39Pjk,1073
|
|
8
|
+
ironflock-1.3.9.dist-info/RECORD,,
|
ironflock-1.3.8.dist-info/RECORD
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
ironflock/CrossbarConnection.py,sha256=S-vjfjIdNpvfNnsPhCV18JHuFN36tpCzqbwxbwHpmow,14301
|
|
2
|
-
ironflock/__init__.py,sha256=FzPwMngsi8VXjeFYfK6D-3a17SbYFIPErWG1cA5qT0c,203
|
|
3
|
-
ironflock/ironflock.py,sha256=sLP0Mfp3bgtyE0Qv7LTUDOY8YlRDM0PxJG8eZplluoU,19536
|
|
4
|
-
ironflock/types.py,sha256=yync91abyrI1fqbmczW5My7Skx7-8soENGHK2JfaEz0,11962
|
|
5
|
-
ironflock-1.3.8.dist-info/METADATA,sha256=257ZiWssGu7T5iqAQtJXZRjp4pXX03peTHaqBLBp0VA,3820
|
|
6
|
-
ironflock-1.3.8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
7
|
-
ironflock-1.3.8.dist-info/licenses/LICENSE,sha256=GpUKjPB381nmkbBIdX74vxXhsNZaNpngTOciss39Pjk,1073
|
|
8
|
-
ironflock-1.3.8.dist-info/RECORD,,
|
|
File without changes
|