ironflock 1.3.7__tar.gz → 1.3.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ironflock
3
- Version: 1.3.7
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.10.2)
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
- Install the necessary build tools if you don't have them already:
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
- pip install --upgrade build twine
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
- Alternatively, you can build manually:
122
+ Or manually:
107
123
 
108
124
  ```shell
109
125
  # Clean previous builds
110
- rm -rf build dist *.egg-info
126
+ rm -rf dist
111
127
 
112
128
  # Build the package
113
- python -m build
129
+ uv build
114
130
 
115
131
  # Upload to PyPI
116
- twine upload dist/*
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
+
@@ -64,30 +64,74 @@ the [examples](https://github.com/RecordEvolution/ironflock-py/tree/main/example
64
64
 
65
65
  ## Development
66
66
 
67
- Install the necessary build tools if you don't have them already:
67
+ This project uses [uv](https://docs.astral.sh/uv/) for dependency management and building.
68
+
69
+ Install uv if you don't have it:
68
70
 
69
71
  ```shell
70
- pip install --upgrade build twine
72
+ curl -LsSf https://astral.sh/uv/install.sh | sh
73
+ ```
74
+
75
+ Install dependencies (including dev dependencies):
76
+
77
+ ```shell
78
+ uv sync --extra dev
79
+ ```
80
+
81
+ Run tests:
82
+
83
+ ```shell
84
+ just test-unit # Run unit tests only
85
+ just test # Run all tests
86
+ just test-docker # Run integration tests with Docker
71
87
  ```
72
88
 
73
- Make sure your pypi API tokens are stored in your `~/.pypirc`.
74
89
  Build and publish a new pypi package:
75
90
 
76
91
  ```shell
77
92
  just publish
78
93
  ```
79
94
 
80
- Alternatively, you can build manually:
95
+ Or manually:
81
96
 
82
97
  ```shell
83
98
  # Clean previous builds
84
- rm -rf build dist *.egg-info
99
+ rm -rf dist
85
100
 
86
101
  # Build the package
87
- python -m build
102
+ uv build
88
103
 
89
104
  # Upload to PyPI
90
- twine upload dist/*
105
+ uv publish
106
+ ```
107
+
108
+ Check the package at https://pypi.org/project/ironflock/.
109
+
110
+ ## Test Deployment
111
+
112
+ To test the package before deploying to PyPI you can use test.pypi.
113
+
114
+ ```shell
115
+ just clean build publish-test
91
116
  ```
92
117
 
93
- Check the package at https://pypi.org/project/ironflock/.
118
+ Or manually:
119
+
120
+ ```shell
121
+ uv build
122
+ uv publish --publish-url https://test.pypi.org/legacy/
123
+ ```
124
+
125
+ Once the package is published you can install it from TestPyPI:
126
+
127
+ ```shell
128
+ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ironflock
129
+ ```
130
+
131
+ Once the package is published you can use it in other code by putting
132
+ these lines at the top of the requirements.txt
133
+
134
+ ```
135
+ --index-url https://test.pypi.org/simple/
136
+ --extra-index-url https://pypi.org/simple/
137
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ironflock"
3
- version = "1.3.7"
3
+ version = "1.3.9"
4
4
  description = "IronFlock Python SDK for connecting to the IronFlock Platform"
5
5
  authors = [
6
6
  {name = "Marko Petzold, IronFlock GmbH", email = "info@ironflock.com"}
@@ -13,7 +13,7 @@ classifiers = [
13
13
  "Intended Audience :: Developers",
14
14
  ]
15
15
  dependencies = [
16
- "autobahn[asyncio,serialization]==25.10.2",
16
+ "autobahn[asyncio,serialization]==25.12.2",
17
17
  "pydantic>=2.0.0"
18
18
  ]
19
19
 
@@ -30,6 +30,7 @@ docs = [
30
30
 
31
31
  dev = [
32
32
  "pytest",
33
+ "pytest-asyncio",
33
34
  "ruff",
34
35
  "pydantic",
35
36
  "mypy",
@@ -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:8080/ws-ua-usr"
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._first_connection_future: Optional[asyncio.Future] = None
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 future for first connection
140
- self._first_connection_future = asyncio.Future()
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
- if self._first_connection_future and not self._first_connection_future.done():
150
- self._first_connection_future.set_result(None)
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
- if self._first_connection_future and not self._first_connection_future.done():
159
- self._first_connection_future.set_exception(
160
- Exception(f"Connection closed: {details.reason}")
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
- start_time = time.time()
172
- while not self.session or not self._is_connected:
173
- if time.time() - start_time > self._session_wait_timeout:
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
- await asyncio.sleep(0.2)
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
- # Start the component (non-blocking in autobahn asyncio)
190
- self.component.start()
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
- # Wait for first connection to be established
193
- if self._first_connection_future:
194
- await self._first_connection_future
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
- ) -> None:
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 = [
@@ -0,0 +1,15 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ from ironflock.ironflock import IronFlock
4
+ from ironflock.CrossbarConnection import CrossbarConnection, Stage
5
+
6
+ __all__ = [
7
+ "IronFlock",
8
+ "CrossbarConnection",
9
+ "Stage"
10
+ ]
11
+
12
+ try:
13
+ __version__ = version("ironflock")
14
+ except PackageNotFoundError:
15
+ __version__ = "0.0.0" # fallback for development
@@ -1,10 +0,0 @@
1
- from ironflock.ironflock import IronFlock
2
- from ironflock.CrossbarConnection import CrossbarConnection, Stage
3
-
4
- __all__ = [
5
- "IronFlock",
6
- "CrossbarConnection",
7
- "Stage"
8
- ]
9
-
10
- __version__ = "1.3.7"
File without changes