ironflock 1.1.0__tar.gz → 1.2.1__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.
- {ironflock-1.1.0/ironflock.egg-info → ironflock-1.2.1}/PKG-INFO +18 -15
- ironflock-1.2.1/pyproject.toml +43 -0
- ironflock-1.2.1/src/ironflock/CrossbarConnection.py +343 -0
- ironflock-1.2.1/src/ironflock/__init__.py +10 -0
- ironflock-1.2.1/src/ironflock/ironflock.py +327 -0
- ironflock-1.1.0/MANIFEST.in +0 -4
- ironflock-1.1.0/PKG-INFO +0 -115
- ironflock-1.1.0/examples/app_session.py +0 -32
- ironflock-1.1.0/examples/component.py +0 -40
- ironflock-1.1.0/examples/simple_publish.py +0 -25
- ironflock-1.1.0/examples/table_publish.py +0 -29
- ironflock-1.1.0/ironflock/AutobahnConnection.py +0 -126
- ironflock-1.1.0/ironflock/__init__.py +0 -10
- ironflock-1.1.0/ironflock/ironflock.py +0 -214
- ironflock-1.1.0/ironflock.egg-info/SOURCES.txt +0 -17
- ironflock-1.1.0/ironflock.egg-info/dependency_links.txt +0 -1
- ironflock-1.1.0/ironflock.egg-info/requires.txt +0 -1
- ironflock-1.1.0/ironflock.egg-info/top_level.txt +0 -4
- ironflock-1.1.0/pyproject.toml +0 -37
- ironflock-1.1.0/setup.cfg +0 -4
- ironflock-1.1.0/test/main.py +0 -10
- {ironflock-1.1.0 → ironflock-1.2.1}/LICENSE +0 -0
- {ironflock-1.1.0 → ironflock-1.2.1}/README.md +0 -0
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ironflock
|
|
3
|
-
Version: 1.1
|
|
4
|
-
Summary:
|
|
5
|
-
Author-email: Record Evolution GmbH <marko.petzold@record-evolution.de>
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: IronFlock Python SDK for connecting to the IronFlock Platform
|
|
6
5
|
License-Expression: MIT
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Marko Petzold, IronFlock GmbH
|
|
8
|
+
Author-email: info@ironflock.com
|
|
9
|
+
Requires-Python: >=3.9
|
|
9
10
|
Classifier: Development Status :: 5 - Production/Stable
|
|
10
11
|
Classifier: Intended Audience :: Developers
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Requires-
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Provides-Extra: docs
|
|
14
|
+
Requires-Dist: autobahn[asyncio,serialization] (==24.4.2)
|
|
15
|
+
Requires-Dist: mypy ; extra == "dev"
|
|
16
|
+
Requires-Dist: pydantic ; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest ; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff ; extra == "dev"
|
|
19
|
+
Requires-Dist: sphinx (>=7) ; extra == "docs"
|
|
20
|
+
Requires-Dist: sphinx-autodoc-typehints (>=2.0) ; extra == "docs"
|
|
21
|
+
Requires-Dist: sphinx-book-theme (>=1.1.4) ; extra == "docs"
|
|
22
|
+
Project-URL: Homepage, https://github.com/RecordEvolution/ironflock-py
|
|
23
|
+
Project-URL: Repository, https://github.com/RecordEvolution/ironflock-py
|
|
18
24
|
Description-Content-Type: text/markdown
|
|
19
|
-
License-File: LICENSE
|
|
20
|
-
Requires-Dist: autobahn[asyncio,serialization]==24.4.2
|
|
21
|
-
Dynamic: license-file
|
|
22
25
|
|
|
23
26
|
# ironflock
|
|
24
27
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ironflock"
|
|
3
|
+
version = "1.2.1"
|
|
4
|
+
description = "IronFlock Python SDK for connecting to the IronFlock Platform"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Marko Petzold, IronFlock GmbH", email = "info@ironflock.com"}
|
|
7
|
+
]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 5 - Production/Stable",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"autobahn[asyncio,serialization]==24.4.2"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/RecordEvolution/ironflock-py"
|
|
21
|
+
Repository = "https://github.com/RecordEvolution/ironflock-py"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
docs = [
|
|
25
|
+
"sphinx (>=7)",
|
|
26
|
+
"sphinx-book-theme (>=1.1.4)",
|
|
27
|
+
"sphinx-autodoc-typehints (>=2.0)"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest",
|
|
32
|
+
"ruff",
|
|
33
|
+
"pydantic",
|
|
34
|
+
"mypy",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
40
|
+
build-backend = "poetry.core.masonry.api"
|
|
41
|
+
|
|
42
|
+
[tool.poetry]
|
|
43
|
+
packages = [{ include = "ironflock", from = "src" }]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional, List, Dict, Any, Callable
|
|
5
|
+
from autobahn.asyncio.wamp import ApplicationSession
|
|
6
|
+
from autobahn.asyncio.component import Component
|
|
7
|
+
from autobahn.wamp import auth
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
class Stage(Enum):
|
|
11
|
+
"""Stage enumeration for different deployment environments"""
|
|
12
|
+
DEVELOPMENT = "DEV"
|
|
13
|
+
PRODUCTION = "PROD"
|
|
14
|
+
|
|
15
|
+
def getSerialNumber(serial_number: str = None) -> str:
|
|
16
|
+
"""Get device serial number from parameter or environment variable"""
|
|
17
|
+
if serial_number is None:
|
|
18
|
+
s_num = os.environ.get("DEVICE_SERIAL_NUMBER")
|
|
19
|
+
if s_num is None:
|
|
20
|
+
raise Exception("ENV Variable 'DEVICE_SERIAL_NUMBER' is not set!")
|
|
21
|
+
else:
|
|
22
|
+
s_num = serial_number
|
|
23
|
+
return s_num
|
|
24
|
+
|
|
25
|
+
DATAPODS_WS_URI = "wss://cbw.datapods.io/ws-ua-usr"
|
|
26
|
+
STUDIO_WS_URI_OLD = "wss://cbw.record-evolution.com/ws-ua-usr"
|
|
27
|
+
STUDIO_WS_URI = "wss://cbw.ironflock.com/ws-ua-usr"
|
|
28
|
+
LOCALHOST_WS_URI = "ws://localhost:8080/ws-ua-usr"
|
|
29
|
+
|
|
30
|
+
socketURIMap = {
|
|
31
|
+
"https://studio.datapods.io": DATAPODS_WS_URI,
|
|
32
|
+
"https://studio.record-evolution.com": STUDIO_WS_URI_OLD,
|
|
33
|
+
"https://studio.ironflock.com": STUDIO_WS_URI,
|
|
34
|
+
"http://localhost:8086": LOCALHOST_WS_URI,
|
|
35
|
+
"http://host.docker.internal:8086": LOCALHOST_WS_URI
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class CrossbarConnection:
|
|
39
|
+
"""
|
|
40
|
+
Python version of the TypeScript CrossbarConnection class.
|
|
41
|
+
Manages WAMP connections with automatic reconnection, subscription management, and session handling.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self.session: Optional[ApplicationSession] = None
|
|
46
|
+
self.component: Optional[Component] = None
|
|
47
|
+
self.connection_options: Optional[Dict[str, Any]] = None
|
|
48
|
+
self.subscriptions: List[Any] = [] # List of autobahn subscriptions
|
|
49
|
+
self.registrations: List[Any] = [] # List of autobahn registrations
|
|
50
|
+
self.connection_drop_wait_time: int = 6000 # milliseconds
|
|
51
|
+
self.realm: Optional[str] = None
|
|
52
|
+
self.serial_number: Optional[str] = None
|
|
53
|
+
self._first_connection_future: Optional[asyncio.Future] = None
|
|
54
|
+
self._is_connected = False
|
|
55
|
+
self._session_wait_timeout = 6.0 # seconds
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def getWebSocketURI():
|
|
59
|
+
reswarm_url = os.environ.get("RESWARM_URL")
|
|
60
|
+
if not reswarm_url:
|
|
61
|
+
return STUDIO_WS_URI
|
|
62
|
+
return socketURIMap.get(reswarm_url)
|
|
63
|
+
|
|
64
|
+
async def configure(
|
|
65
|
+
self,
|
|
66
|
+
swarm_key: int,
|
|
67
|
+
app_key: int,
|
|
68
|
+
stage: Stage,
|
|
69
|
+
cburl: Optional[str] = None,
|
|
70
|
+
serial_number: Optional[str] = None
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Configure the crossbar connection with realm and WAMPCRA authentication details.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
swarm_key: The swarm key identifier
|
|
77
|
+
app_key: The application key identifier
|
|
78
|
+
stage: The deployment stage (development, production)
|
|
79
|
+
cburl: Optional crossbar URL (defaults to environment variable RESWARM_URL mapping)
|
|
80
|
+
serial_number: Optional device serial number (defaults to DEVICE_SERIAL_NUMBER env var)
|
|
81
|
+
"""
|
|
82
|
+
self.realm = f"realm-{swarm_key}-{app_key}-{stage.value.lower()}"
|
|
83
|
+
self.serial_number = getSerialNumber(serial_number)
|
|
84
|
+
|
|
85
|
+
# Get URL from environment if not provided
|
|
86
|
+
if cburl is None:
|
|
87
|
+
cburl = self.getWebSocketURI()
|
|
88
|
+
|
|
89
|
+
self.connection_options = {
|
|
90
|
+
'url': cburl,
|
|
91
|
+
'realm': self.realm,
|
|
92
|
+
'authmethods': ['wampcra'],
|
|
93
|
+
'authid': self.serial_number,
|
|
94
|
+
'max_retries': -1, # infinite retries
|
|
95
|
+
'max_retry_delay': 2,
|
|
96
|
+
'initial_retry_delay': 1,
|
|
97
|
+
'serializers': ['msgpack']
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Create the component with WAMPCRA authentication
|
|
101
|
+
transports = [{
|
|
102
|
+
'type': 'websocket',
|
|
103
|
+
'url': cburl,
|
|
104
|
+
'serializers': ['msgpack']
|
|
105
|
+
}]
|
|
106
|
+
|
|
107
|
+
# Custom session class for WAMPCRA authentication
|
|
108
|
+
class AppSession(ApplicationSession):
|
|
109
|
+
def onConnect(session_self):
|
|
110
|
+
print('onConnect called')
|
|
111
|
+
session_self.join(self.realm, ['wampcra'], self.serial_number)
|
|
112
|
+
|
|
113
|
+
def onChallenge(session_self, challenge):
|
|
114
|
+
print(f'challenge requested for {challenge.method}')
|
|
115
|
+
if challenge.method == "wampcra":
|
|
116
|
+
signature = auth.compute_wcs(
|
|
117
|
+
self.serial_number, challenge.extra["challenge"]
|
|
118
|
+
)
|
|
119
|
+
return signature
|
|
120
|
+
raise Exception(f"Invalid authmethod {challenge.method}")
|
|
121
|
+
|
|
122
|
+
self.component = Component(
|
|
123
|
+
transports=transports,
|
|
124
|
+
realm=self.realm,
|
|
125
|
+
session_factory=AppSession
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Set up event handlers
|
|
129
|
+
self.component.on('join', self._on_open)
|
|
130
|
+
self.component.on('leave', self._on_close)
|
|
131
|
+
self.component.on('disconnect', self._on_disconnect)
|
|
132
|
+
|
|
133
|
+
# Create future for first connection
|
|
134
|
+
self._first_connection_future = asyncio.Future()
|
|
135
|
+
|
|
136
|
+
async def _on_open(self, session: ApplicationSession, details):
|
|
137
|
+
"""Handle session open event"""
|
|
138
|
+
self.session = session
|
|
139
|
+
self._is_connected = True
|
|
140
|
+
await self._resubscribe_all()
|
|
141
|
+
print(f"Connection to IronFlock app realm '{session._realm}' established")
|
|
142
|
+
|
|
143
|
+
if self._first_connection_future and not self._first_connection_future.done():
|
|
144
|
+
self._first_connection_future.set_result(None)
|
|
145
|
+
|
|
146
|
+
async def _on_close(self, session: ApplicationSession, details):
|
|
147
|
+
"""Handle session close event"""
|
|
148
|
+
self.session = None
|
|
149
|
+
self._is_connected = False
|
|
150
|
+
print(f"Connection to IronFlock app realm {self.realm} closed: {details.reason}")
|
|
151
|
+
|
|
152
|
+
if self._first_connection_future and not self._first_connection_future.done():
|
|
153
|
+
self._first_connection_future.set_exception(
|
|
154
|
+
Exception(f"Connection closed: {details.reason}")
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def _on_disconnect(self, session: ApplicationSession, was_clean: bool):
|
|
158
|
+
"""Handle disconnect event"""
|
|
159
|
+
self.session = None
|
|
160
|
+
self._is_connected = False
|
|
161
|
+
print(f"Disconnected from IronFlock app realm {self.realm}, clean: {was_clean}")
|
|
162
|
+
|
|
163
|
+
async def _session_wait(self) -> None:
|
|
164
|
+
"""Wait for session to be available"""
|
|
165
|
+
start_time = time.time()
|
|
166
|
+
while not self.session or not self._is_connected:
|
|
167
|
+
if time.time() - start_time > self._session_wait_timeout:
|
|
168
|
+
raise TimeoutError('Timeout waiting for session')
|
|
169
|
+
await asyncio.sleep(0.2)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def is_open(self) -> bool:
|
|
173
|
+
"""Check if the connection is open"""
|
|
174
|
+
return self._is_connected and self.session is not None
|
|
175
|
+
|
|
176
|
+
async def start(self) -> None:
|
|
177
|
+
"""Start the connection"""
|
|
178
|
+
if not self.component:
|
|
179
|
+
raise ValueError("Must call configure() before start()")
|
|
180
|
+
|
|
181
|
+
print(f'Starting connection for IronFlock app realm {self.realm}')
|
|
182
|
+
|
|
183
|
+
# Start the component (non-blocking in autobahn asyncio)
|
|
184
|
+
self.component.start()
|
|
185
|
+
|
|
186
|
+
# Wait for first connection to be established
|
|
187
|
+
if self._first_connection_future:
|
|
188
|
+
await self._first_connection_future
|
|
189
|
+
|
|
190
|
+
async def stop(self) -> None:
|
|
191
|
+
"""Stop the connection"""
|
|
192
|
+
if self.component:
|
|
193
|
+
await self.component.stop()
|
|
194
|
+
self._is_connected = False
|
|
195
|
+
self.session = None
|
|
196
|
+
|
|
197
|
+
async def call(
|
|
198
|
+
self,
|
|
199
|
+
topic: str,
|
|
200
|
+
args: Optional[List[Any]] = None,
|
|
201
|
+
kwargs: Optional[Dict[str, Any]] = None,
|
|
202
|
+
options: Optional[Dict[str, Any]] = None
|
|
203
|
+
) -> Any:
|
|
204
|
+
"""Call a remote procedure"""
|
|
205
|
+
await self._session_wait()
|
|
206
|
+
if not self.session:
|
|
207
|
+
raise RuntimeError("No active session")
|
|
208
|
+
|
|
209
|
+
args = args or []
|
|
210
|
+
kwargs = kwargs or {}
|
|
211
|
+
|
|
212
|
+
result = await self.session.call(topic, *args, **kwargs, options=options)
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
async def subscribe(
|
|
216
|
+
self,
|
|
217
|
+
topic: str,
|
|
218
|
+
handler: Callable,
|
|
219
|
+
options: Optional[Dict[str, Any]] = None
|
|
220
|
+
) -> Optional[Any]:
|
|
221
|
+
"""Subscribe to a topic"""
|
|
222
|
+
await self._session_wait()
|
|
223
|
+
if not self.session:
|
|
224
|
+
raise RuntimeError("No active session")
|
|
225
|
+
|
|
226
|
+
subscription = await self.session.subscribe(handler, topic, options=options)
|
|
227
|
+
if subscription:
|
|
228
|
+
self.subscriptions.append(subscription)
|
|
229
|
+
return subscription
|
|
230
|
+
|
|
231
|
+
async def publish(
|
|
232
|
+
self,
|
|
233
|
+
topic: str,
|
|
234
|
+
args: Optional[List[Any]] = None,
|
|
235
|
+
kwargs: Optional[Dict[str, Any]] = None,
|
|
236
|
+
options: Optional[Dict[str, Any]] = None
|
|
237
|
+
) -> Optional[Any]:
|
|
238
|
+
"""Publish to a topic"""
|
|
239
|
+
await self._session_wait()
|
|
240
|
+
if not self.session:
|
|
241
|
+
raise RuntimeError("No active session")
|
|
242
|
+
|
|
243
|
+
args = args or []
|
|
244
|
+
kwargs = kwargs or {}
|
|
245
|
+
|
|
246
|
+
result = await self.session.publish(topic, *args, options=options, **kwargs)
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
async def unsubscribe(self, subscription: Any) -> None:
|
|
250
|
+
"""Unsubscribe from a subscription"""
|
|
251
|
+
await self._session_wait()
|
|
252
|
+
if not self.session:
|
|
253
|
+
raise RuntimeError("No active session")
|
|
254
|
+
|
|
255
|
+
if subscription in self.subscriptions:
|
|
256
|
+
self.subscriptions.remove(subscription)
|
|
257
|
+
await self.session.unsubscribe(subscription)
|
|
258
|
+
|
|
259
|
+
async def unsubscribe_func(
|
|
260
|
+
self,
|
|
261
|
+
topic: str,
|
|
262
|
+
handler: Optional[Callable] = None
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Unsubscribe a specific function from a topic"""
|
|
265
|
+
await self._session_wait()
|
|
266
|
+
|
|
267
|
+
if handler is None:
|
|
268
|
+
return await self.unsubscribe_topic(topic)
|
|
269
|
+
|
|
270
|
+
# Find and remove subscriptions matching topic and handler
|
|
271
|
+
to_remove = []
|
|
272
|
+
for subscription in self.subscriptions:
|
|
273
|
+
if hasattr(subscription, 'topic') and subscription.topic == topic:
|
|
274
|
+
if hasattr(subscription, 'handler') and subscription.handler == handler:
|
|
275
|
+
to_remove.append(subscription)
|
|
276
|
+
|
|
277
|
+
for subscription in to_remove:
|
|
278
|
+
try:
|
|
279
|
+
await self.unsubscribe(subscription)
|
|
280
|
+
except Exception as e:
|
|
281
|
+
print(f"Failed to unsubscribe from {topic}: {e}")
|
|
282
|
+
|
|
283
|
+
async def unsubscribe_topic(self, topic: str) -> bool:
|
|
284
|
+
"""Unsubscribe all handlers from a topic"""
|
|
285
|
+
matching_subs = [
|
|
286
|
+
sub for sub in self.subscriptions
|
|
287
|
+
if hasattr(sub, 'topic') and sub.topic == topic
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
if not matching_subs:
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
# Unsubscribe all matching subscriptions
|
|
294
|
+
tasks = [self.unsubscribe(sub) for sub in matching_subs]
|
|
295
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
async def register(
|
|
299
|
+
self,
|
|
300
|
+
topic: str,
|
|
301
|
+
endpoint: Callable,
|
|
302
|
+
options: Optional[Dict[str, Any]] = None
|
|
303
|
+
) -> Optional[Any]:
|
|
304
|
+
"""Register a procedure"""
|
|
305
|
+
await self._session_wait()
|
|
306
|
+
if not self.session:
|
|
307
|
+
raise RuntimeError("No active session")
|
|
308
|
+
|
|
309
|
+
registration = await self.session.register(endpoint, topic, options=options)
|
|
310
|
+
if registration:
|
|
311
|
+
self.registrations.append(registration)
|
|
312
|
+
return registration
|
|
313
|
+
|
|
314
|
+
async def unregister(self, registration: Any) -> None:
|
|
315
|
+
"""Unregister a procedure"""
|
|
316
|
+
await self._session_wait()
|
|
317
|
+
if not self.session:
|
|
318
|
+
raise RuntimeError("No active session")
|
|
319
|
+
|
|
320
|
+
if registration in self.registrations:
|
|
321
|
+
self.registrations.remove(registration)
|
|
322
|
+
await self.session.unregister(registration)
|
|
323
|
+
|
|
324
|
+
async def _resubscribe_all(self) -> None:
|
|
325
|
+
"""Resubscribe to all topics after reconnection"""
|
|
326
|
+
if not self.subscriptions:
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
print(f'Resubscribing to {len(self.subscriptions)} subscriptions')
|
|
330
|
+
|
|
331
|
+
# Store current subscriptions and clear the list
|
|
332
|
+
old_subscriptions = self.subscriptions.copy()
|
|
333
|
+
self.subscriptions.clear()
|
|
334
|
+
|
|
335
|
+
# Resubscribe to all topics
|
|
336
|
+
tasks = []
|
|
337
|
+
for old_sub in old_subscriptions:
|
|
338
|
+
if hasattr(old_sub, 'topic') and hasattr(old_sub, 'handler'):
|
|
339
|
+
task = self.subscribe(old_sub.topic, old_sub.handler)
|
|
340
|
+
tasks.append(task)
|
|
341
|
+
|
|
342
|
+
if tasks:
|
|
343
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|