ironflock 1.3.7__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 -23
- ironflock/__init__.py +6 -1
- {ironflock-1.3.7.dist-info → ironflock-1.3.9.dist-info}/METADATA +55 -9
- ironflock-1.3.9.dist-info/RECORD +8 -0
- {ironflock-1.3.7.dist-info → ironflock-1.3.9.dist-info}/WHEEL +1 -1
- ironflock-1.3.7.dist-info/RECORD +0 -8
- {ironflock-1.3.7.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,24 +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
|
-
|
|
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()
|
|
162
170
|
|
|
163
171
|
async def _on_disconnect(self, session: ApplicationSession, was_clean: bool):
|
|
164
172
|
"""Handle disconnect event"""
|
|
165
173
|
self.session = None
|
|
166
174
|
self._is_connected = False
|
|
167
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()
|
|
168
180
|
|
|
169
181
|
async def _session_wait(self) -> None:
|
|
170
182
|
"""Wait for session to be available"""
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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:
|
|
174
194
|
raise TimeoutError('Timeout waiting for session')
|
|
175
|
-
|
|
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')
|
|
176
206
|
|
|
177
207
|
@property
|
|
178
208
|
def is_open(self) -> bool:
|
|
@@ -180,18 +210,112 @@ class CrossbarConnection:
|
|
|
180
210
|
return self._is_connected and self.session is not None
|
|
181
211
|
|
|
182
212
|
async def start(self) -> None:
|
|
183
|
-
"""Start the connection"""
|
|
213
|
+
"""Start the connection with timeout and retry logic"""
|
|
184
214
|
if not self.component:
|
|
185
215
|
raise ValueError("Must call configure() before start()")
|
|
186
216
|
|
|
187
217
|
print(f'Starting connection for IronFlock app realm {self.realm}')
|
|
188
218
|
|
|
189
|
-
|
|
190
|
-
|
|
219
|
+
last_error: Optional[Exception] = None
|
|
220
|
+
|
|
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
|
|
191
279
|
|
|
192
|
-
#
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
195
319
|
|
|
196
320
|
async def stop(self) -> None:
|
|
197
321
|
"""Stop the connection"""
|
|
@@ -293,7 +417,7 @@ class CrossbarConnection:
|
|
|
293
417
|
self,
|
|
294
418
|
topic: str,
|
|
295
419
|
handler: Optional[Callable] = None
|
|
296
|
-
) ->
|
|
420
|
+
) -> bool:
|
|
297
421
|
"""Unsubscribe a specific function from a topic"""
|
|
298
422
|
await self._session_wait()
|
|
299
423
|
|
|
@@ -312,7 +436,9 @@ class CrossbarConnection:
|
|
|
312
436
|
await self.unsubscribe(subscription)
|
|
313
437
|
except Exception as e:
|
|
314
438
|
print(f"Failed to unsubscribe from {topic}: {e}")
|
|
315
|
-
|
|
439
|
+
return False
|
|
440
|
+
return True
|
|
441
|
+
|
|
316
442
|
async def unsubscribe_topic(self, topic: str) -> bool:
|
|
317
443
|
"""Unsubscribe all handlers from a topic"""
|
|
318
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,30 +91,75 @@ 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/.
|
|
136
|
+
|
|
137
|
+
## Test Deployment
|
|
138
|
+
|
|
139
|
+
To test the package before deploying to PyPI you can use test.pypi.
|
|
140
|
+
|
|
141
|
+
```shell
|
|
142
|
+
just clean build publish-test
|
|
143
|
+
```
|
|
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
|
+
|
|
158
|
+
Once the package is published you can use it in other code by putting
|
|
159
|
+
these lines at the top of the requirements.txt
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
--index-url https://test.pypi.org/simple/
|
|
163
|
+
--extra-index-url https://pypi.org/simple/
|
|
164
|
+
```
|
|
165
|
+
|
|
@@ -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.7.dist-info/RECORD
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
ironflock/CrossbarConnection.py,sha256=WcxFl7ai_59G3-_AdPAMU1mE0TWDaQ1qLARG0rIyMPk,14151
|
|
2
|
-
ironflock/__init__.py,sha256=pOMdngbXI4dydtcQCeRyP2NdJ40M2Tz50--twGIJRNU,203
|
|
3
|
-
ironflock/ironflock.py,sha256=sLP0Mfp3bgtyE0Qv7LTUDOY8YlRDM0PxJG8eZplluoU,19536
|
|
4
|
-
ironflock/types.py,sha256=yync91abyrI1fqbmczW5My7Skx7-8soENGHK2JfaEz0,11962
|
|
5
|
-
ironflock-1.3.7.dist-info/METADATA,sha256=8zX72CW1w5Xti5_Nam0WvInGgx-fKfhDopcjeMd2C-o,3473
|
|
6
|
-
ironflock-1.3.7.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
7
|
-
ironflock-1.3.7.dist-info/licenses/LICENSE,sha256=GpUKjPB381nmkbBIdX74vxXhsNZaNpngTOciss39Pjk,1073
|
|
8
|
-
ironflock-1.3.7.dist-info/RECORD,,
|
|
File without changes
|