ironflock 1.3.0__py3-none-any.whl → 1.3.2__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 +40 -9
- ironflock/__init__.py +1 -1
- ironflock/ironflock.py +188 -27
- ironflock/types.py +262 -0
- {ironflock-1.3.0.dist-info → ironflock-1.3.2.dist-info}/METADATA +2 -1
- ironflock-1.3.2.dist-info/RECORD +8 -0
- ironflock-1.3.0.dist-info/RECORD +0 -7
- {ironflock-1.3.0.dist-info → ironflock-1.3.2.dist-info}/WHEEL +0 -0
- {ironflock-1.3.0.dist-info → ironflock-1.3.2.dist-info}/licenses/LICENSE +0 -0
ironflock/CrossbarConnection.py
CHANGED
|
@@ -7,6 +7,12 @@ from autobahn.asyncio.component import Component
|
|
|
7
7
|
from autobahn.wamp import auth
|
|
8
8
|
from enum import Enum
|
|
9
9
|
|
|
10
|
+
# Import Pydantic types for validation
|
|
11
|
+
try:
|
|
12
|
+
from .types import CrossbarCallParams, SubscriptionParams
|
|
13
|
+
except ImportError:
|
|
14
|
+
from ironflock.types import CrossbarCallParams, SubscriptionParams
|
|
15
|
+
|
|
10
16
|
class Stage(Enum):
|
|
11
17
|
"""Stage enumeration for different deployment environments"""
|
|
12
18
|
DEVELOPMENT = "DEV"
|
|
@@ -202,14 +208,22 @@ class CrossbarConnection:
|
|
|
202
208
|
options: Optional[Dict[str, Any]] = None
|
|
203
209
|
) -> Any:
|
|
204
210
|
"""Call a remote procedure"""
|
|
211
|
+
# Validate parameters using Pydantic
|
|
212
|
+
try:
|
|
213
|
+
params = CrossbarCallParams(
|
|
214
|
+
topic=topic,
|
|
215
|
+
args=args or [],
|
|
216
|
+
kwargs=kwargs or {},
|
|
217
|
+
options=options
|
|
218
|
+
)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
raise ValueError(f"Invalid call parameters: {e}")
|
|
221
|
+
|
|
205
222
|
await self._session_wait()
|
|
206
223
|
if not self.session:
|
|
207
224
|
raise RuntimeError("No active session")
|
|
208
225
|
|
|
209
|
-
|
|
210
|
-
kwargs = kwargs or {}
|
|
211
|
-
|
|
212
|
-
result = await self.session.call(topic, *args, **kwargs, options=options)
|
|
226
|
+
result = await self.session.call(params.topic, *params.args, **params.kwargs, options=params.options)
|
|
213
227
|
return result
|
|
214
228
|
|
|
215
229
|
async def subscribe(
|
|
@@ -219,11 +233,20 @@ class CrossbarConnection:
|
|
|
219
233
|
options: Optional[Dict[str, Any]] = None
|
|
220
234
|
) -> Optional[Any]:
|
|
221
235
|
"""Subscribe to a topic"""
|
|
236
|
+
# Validate parameters using Pydantic
|
|
237
|
+
try:
|
|
238
|
+
params = SubscriptionParams(
|
|
239
|
+
topic=topic,
|
|
240
|
+
options=options
|
|
241
|
+
)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
raise ValueError(f"Invalid subscription parameters: {e}")
|
|
244
|
+
|
|
222
245
|
await self._session_wait()
|
|
223
246
|
if not self.session:
|
|
224
247
|
raise RuntimeError("No active session")
|
|
225
248
|
|
|
226
|
-
subscription = await self.session.subscribe(handler, topic, options=options)
|
|
249
|
+
subscription = await self.session.subscribe(handler, params.topic, options=params.options)
|
|
227
250
|
if subscription:
|
|
228
251
|
self.subscriptions.append(subscription)
|
|
229
252
|
return subscription
|
|
@@ -236,14 +259,22 @@ class CrossbarConnection:
|
|
|
236
259
|
options: Optional[Dict[str, Any]] = None
|
|
237
260
|
) -> Optional[Any]:
|
|
238
261
|
"""Publish to a topic"""
|
|
262
|
+
# Validate parameters using Pydantic
|
|
263
|
+
try:
|
|
264
|
+
params = CrossbarCallParams( # Using CrossbarCallParams as it has the same structure
|
|
265
|
+
topic=topic,
|
|
266
|
+
args=args or [],
|
|
267
|
+
kwargs=kwargs or {},
|
|
268
|
+
options=options
|
|
269
|
+
)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise ValueError(f"Invalid publish parameters: {e}")
|
|
272
|
+
|
|
239
273
|
await self._session_wait()
|
|
240
274
|
if not self.session:
|
|
241
275
|
raise RuntimeError("No active session")
|
|
242
276
|
|
|
243
|
-
|
|
244
|
-
kwargs = kwargs or {}
|
|
245
|
-
|
|
246
|
-
result = await self.session.publish(topic, *args, options=options, **kwargs)
|
|
277
|
+
result = await self.session.publish(params.topic, *params.args, options=params.options, **params.kwargs)
|
|
247
278
|
return result
|
|
248
279
|
|
|
249
280
|
async def unsubscribe(self, subscription: Any) -> None:
|
ironflock/__init__.py
CHANGED
ironflock/ironflock.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import asyncio
|
|
3
|
-
from typing import Optional, Any
|
|
3
|
+
from typing import Optional, Any, Union
|
|
4
4
|
|
|
5
5
|
from ironflock.CrossbarConnection import CrossbarConnection, Stage, getSerialNumber
|
|
6
|
+
from ironflock.types import TableQueryParams, PublishParams, CallParams, LocationParams, TableParams, SubscriptionParams
|
|
6
7
|
from autobahn.wamp.types import PublishOptions, RegisterOptions, SubscribeOptions, CallOptions
|
|
8
|
+
import warnings
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
class IronFlock:
|
|
@@ -34,10 +36,29 @@ class IronFlock:
|
|
|
34
36
|
self._serial_number = getSerialNumber(serial_number)
|
|
35
37
|
self._device_name = os.environ.get("DEVICE_NAME")
|
|
36
38
|
self._device_key = os.environ.get("DEVICE_KEY")
|
|
39
|
+
self._app_name = os.environ.get("APP_NAME")
|
|
40
|
+
self._swarm_key = int(os.environ.get("SWARM_KEY"))
|
|
41
|
+
self._app_key = int(os.environ.get("APP_KEY"))
|
|
37
42
|
self._connection = CrossbarConnection()
|
|
38
43
|
self.mainFunc = mainFunc
|
|
39
44
|
self._main_task = None
|
|
40
45
|
self._is_configured = False
|
|
46
|
+
# Validate required environment variables
|
|
47
|
+
missing_vars = []
|
|
48
|
+
if not self._device_key:
|
|
49
|
+
missing_vars.append("DEVICE_KEY")
|
|
50
|
+
if not self._app_name:
|
|
51
|
+
missing_vars.append("APP_NAME")
|
|
52
|
+
if not self._serial_number:
|
|
53
|
+
missing_vars.append("DEVICE_SERIAL_NUMBER")
|
|
54
|
+
if not self._swarm_key:
|
|
55
|
+
missing_vars.append("SWARM_KEY")
|
|
56
|
+
if not self._app_key:
|
|
57
|
+
missing_vars.append("APP_KEY")
|
|
58
|
+
|
|
59
|
+
if missing_vars:
|
|
60
|
+
warning_msg = f"Warning: The following environment variables must be present: {', '.join(missing_vars)}"
|
|
61
|
+
warnings.warn(warning_msg, UserWarning, stacklevel=2)
|
|
41
62
|
|
|
42
63
|
@property
|
|
43
64
|
def connection(self) -> CrossbarConnection:
|
|
@@ -62,8 +83,6 @@ class IronFlock:
|
|
|
62
83
|
if self._is_configured:
|
|
63
84
|
return
|
|
64
85
|
|
|
65
|
-
swarm_key = int(os.environ.get("SWARM_KEY", 0))
|
|
66
|
-
app_key = int(os.environ.get("APP_KEY", 0))
|
|
67
86
|
env_value = os.environ.get("ENV", "DEV").upper()
|
|
68
87
|
|
|
69
88
|
# Map environment string to Stage enum
|
|
@@ -74,12 +93,22 @@ class IronFlock:
|
|
|
74
93
|
stage = stage_map.get(env_value, Stage.DEVELOPMENT)
|
|
75
94
|
|
|
76
95
|
await self._connection.configure(
|
|
77
|
-
swarm_key=
|
|
78
|
-
app_key=
|
|
96
|
+
swarm_key=int(self._swarm_key),
|
|
97
|
+
app_key=int(self._app_key),
|
|
79
98
|
stage=stage,
|
|
80
99
|
serial_number=self._serial_number
|
|
81
100
|
)
|
|
82
101
|
self._is_configured = True
|
|
102
|
+
|
|
103
|
+
def getRemoteAccessUrlForPort(self, port: int) -> Optional[str]:
|
|
104
|
+
"""Get the remote access URL for a given port from the CrossbarConnection
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
port (int): The port number to get the URL for
|
|
108
|
+
Returns:
|
|
109
|
+
Optional[str]: The remote access URL or None if not available
|
|
110
|
+
"""
|
|
111
|
+
return f"https://{self._device_key}-{self._app_name.lower()}-{port}.app.ironflock.com" if self._device_key and self._app_name else None
|
|
83
112
|
|
|
84
113
|
async def publish(self, topic: str, *args, **kwargs) -> Optional[Any]:
|
|
85
114
|
"""Publishes to the IronFlock Platform Message Router
|
|
@@ -93,6 +122,16 @@ class IronFlock:
|
|
|
93
122
|
Optional[Any]: Object representing a publication
|
|
94
123
|
(feedback from publishing an event when doing an acknowledged publish)
|
|
95
124
|
"""
|
|
125
|
+
# Validate parameters using Pydantic
|
|
126
|
+
try:
|
|
127
|
+
params = PublishParams(
|
|
128
|
+
topic=topic,
|
|
129
|
+
args=list(args),
|
|
130
|
+
kwargs=kwargs
|
|
131
|
+
)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
raise ValueError(f"Invalid publish parameters: {e}")
|
|
134
|
+
|
|
96
135
|
if not self.is_connected:
|
|
97
136
|
print("cannot publish, not connected")
|
|
98
137
|
return None
|
|
@@ -105,15 +144,15 @@ class IronFlock:
|
|
|
105
144
|
}
|
|
106
145
|
|
|
107
146
|
# Merge device metadata with user kwargs
|
|
108
|
-
combined_kwargs = {**device_metadata, **kwargs}
|
|
147
|
+
combined_kwargs = {**device_metadata, **params.kwargs}
|
|
109
148
|
|
|
110
149
|
# Use acknowledged publish with proper PublishOptions
|
|
111
150
|
options = PublishOptions(acknowledge=True)
|
|
112
151
|
|
|
113
152
|
try:
|
|
114
153
|
pub = await self._connection.publish(
|
|
115
|
-
topic,
|
|
116
|
-
args=
|
|
154
|
+
params.topic,
|
|
155
|
+
args=params.args,
|
|
117
156
|
kwargs=combined_kwargs,
|
|
118
157
|
options=options
|
|
119
158
|
)
|
|
@@ -134,13 +173,19 @@ class IronFlock:
|
|
|
134
173
|
long (float): Longitude coordinate
|
|
135
174
|
lat (float): Latitude coordinate
|
|
136
175
|
"""
|
|
176
|
+
# Validate parameters using Pydantic
|
|
177
|
+
try:
|
|
178
|
+
params = LocationParams(longitude=long, latitude=lat)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
raise ValueError(f"Invalid location parameters: {e}")
|
|
181
|
+
|
|
137
182
|
if not self.is_connected:
|
|
138
183
|
print("cannot set location, not connected")
|
|
139
184
|
return None
|
|
140
185
|
|
|
141
186
|
payload = {
|
|
142
|
-
"long":
|
|
143
|
-
"lat":
|
|
187
|
+
"long": params.longitude,
|
|
188
|
+
"lat": params.latitude
|
|
144
189
|
}
|
|
145
190
|
|
|
146
191
|
extra = {
|
|
@@ -227,20 +272,29 @@ class IronFlock:
|
|
|
227
272
|
Returns:
|
|
228
273
|
Any: The result of the remote procedure call
|
|
229
274
|
"""
|
|
275
|
+
# Validate parameters using Pydantic
|
|
276
|
+
try:
|
|
277
|
+
params = CallParams(
|
|
278
|
+
device_key=device_key,
|
|
279
|
+
topic=topic,
|
|
280
|
+
args=args or [],
|
|
281
|
+
kwargs=kwargs or {},
|
|
282
|
+
options=options
|
|
283
|
+
)
|
|
284
|
+
except Exception as e:
|
|
285
|
+
raise ValueError(f"Invalid call parameters: {e}")
|
|
286
|
+
|
|
230
287
|
if not self.is_connected:
|
|
231
288
|
print("cannot call, not connected")
|
|
232
289
|
return None
|
|
233
290
|
|
|
234
|
-
args = args or []
|
|
235
|
-
kwargs = kwargs or {}
|
|
236
|
-
|
|
237
291
|
# Convert options dict to CallOptions if provided
|
|
238
|
-
call_options = CallOptions(**options) if options else None
|
|
292
|
+
call_options = CallOptions(**params.options) if params.options else None
|
|
239
293
|
|
|
240
|
-
call_topic = f"{device_key}.{topic}"
|
|
294
|
+
call_topic = f"{params.device_key}.{params.topic}"
|
|
241
295
|
|
|
242
296
|
try:
|
|
243
|
-
result = await self._connection.call(call_topic, args=args, kwargs=kwargs, options=call_options)
|
|
297
|
+
result = await self._connection.call(call_topic, args=params.args, kwargs=params.kwargs, options=call_options)
|
|
244
298
|
return result
|
|
245
299
|
except Exception as e:
|
|
246
300
|
print(f"Call failed: {e}")
|
|
@@ -267,23 +321,130 @@ class IronFlock:
|
|
|
267
321
|
Optional[Any]: Object representing a publication
|
|
268
322
|
(feedback from publishing an event when doing an acknowledged publish)
|
|
269
323
|
"""
|
|
324
|
+
# Validate parameters using Pydantic
|
|
325
|
+
try:
|
|
326
|
+
params = TableParams(
|
|
327
|
+
tablename=tablename,
|
|
328
|
+
args=list(args),
|
|
329
|
+
kwargs=kwargs
|
|
330
|
+
)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
raise ValueError(f"Invalid table parameters: {e}")
|
|
270
333
|
|
|
271
|
-
if not
|
|
272
|
-
raise Exception("
|
|
334
|
+
if not self._swarm_key:
|
|
335
|
+
raise Exception("SWARM_KEY not set in environment variables!")
|
|
273
336
|
|
|
274
|
-
|
|
275
|
-
|
|
337
|
+
if not self._app_key:
|
|
338
|
+
raise Exception("APP_KEY not set in environment variables!")
|
|
276
339
|
|
|
277
|
-
|
|
278
|
-
raise Exception("Environment variable SWARM_KEY not set!")
|
|
340
|
+
topic = f"{self._swarm_key}.{self._app_key}.{params.tablename}"
|
|
279
341
|
|
|
280
|
-
|
|
281
|
-
|
|
342
|
+
pub = await self.publish(topic, *params.args, **params.kwargs)
|
|
343
|
+
return pub
|
|
282
344
|
|
|
283
|
-
|
|
345
|
+
async def subscribe_to_table(
|
|
346
|
+
self, tablename: str, handler, options: Optional[dict] = None
|
|
347
|
+
) -> Optional[Any]:
|
|
348
|
+
"""Subscribes to a Table in the IronFlock Platform. This is a convenience function.
|
|
349
|
+
|
|
350
|
+
You can achieve the same results by simply subscribing to the topic
|
|
351
|
+
[SWARM_KEY].[APP_KEY].[your_table_name]
|
|
352
|
+
|
|
353
|
+
The SWARM_KEY and APP_KEY are provided as environment variables to the device container.
|
|
354
|
+
The also provided ENV variable holds either PROD or DEV to decide which topic to use, above.
|
|
355
|
+
This function automatically detects the environment and subscribes to the correct table.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
tablename (str): The table name of the table to subscribe to, e.g. "sensordata"
|
|
359
|
+
handler: The function to call when a message is received
|
|
360
|
+
options (dict, optional): Subscription options
|
|
284
361
|
|
|
285
|
-
|
|
286
|
-
|
|
362
|
+
Returns:
|
|
363
|
+
Optional[Any]: Object representing a subscription
|
|
364
|
+
"""
|
|
365
|
+
# Validate parameters using Pydantic
|
|
366
|
+
try:
|
|
367
|
+
params = SubscriptionParams(
|
|
368
|
+
topic=tablename, # We validate the tablename as a topic
|
|
369
|
+
options=options
|
|
370
|
+
)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
raise ValueError(f"Invalid subscription parameters: {e}")
|
|
373
|
+
|
|
374
|
+
if not self._swarm_key:
|
|
375
|
+
raise Exception("SWARM_KEY not set in environment variables!")
|
|
376
|
+
|
|
377
|
+
if not self._app_key:
|
|
378
|
+
raise Exception("APP_KEY not set in environment variables!")
|
|
379
|
+
|
|
380
|
+
topic = f"{self._swarm_key}.{self._app_key}.{params.topic}"
|
|
381
|
+
|
|
382
|
+
sub = await self.subscribe(topic, handler, params.options)
|
|
383
|
+
return sub
|
|
384
|
+
|
|
385
|
+
async def getHistory(self, tablename: str, queryParams: Union[TableQueryParams, dict]) -> Optional[Any]:
|
|
386
|
+
"""Get historical data from a table using the history service
|
|
387
|
+
|
|
388
|
+
Calls the "history.table" topic with the specified table name and query parameters.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
tablename (str): The name of the table to query
|
|
392
|
+
queryParams (TableQueryParams | dict): Query parameters including:
|
|
393
|
+
- limit (int): Maximum number of rows (required, 1-10000)
|
|
394
|
+
- offset (int, optional): Offset for pagination (>=0)
|
|
395
|
+
- timeRange (ISOTimeRange, optional): Time range filter with start/end ISO dates
|
|
396
|
+
- filterAnd (List[SQLFilterAnd], optional): AND conditions for filtering
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
Optional[Any]: The query result data or None if the call fails
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
ValueError: If validation fails for any parameter
|
|
403
|
+
pydantic.ValidationError: If Pydantic validation fails
|
|
404
|
+
"""
|
|
405
|
+
if not tablename:
|
|
406
|
+
raise ValueError("Tablename must not be None or empty string!")
|
|
407
|
+
|
|
408
|
+
# Validate and convert parameters using Pydantic BEFORE checking connection
|
|
409
|
+
try:
|
|
410
|
+
if isinstance(queryParams, dict):
|
|
411
|
+
# Convert dict to Pydantic model (this triggers validation)
|
|
412
|
+
validated_params = TableQueryParams(**queryParams)
|
|
413
|
+
else:
|
|
414
|
+
# Already a Pydantic model, but validate again to be safe
|
|
415
|
+
validated_params = queryParams
|
|
416
|
+
|
|
417
|
+
except Exception as e:
|
|
418
|
+
raise ValueError(f"Invalid query parameters: {e}")
|
|
419
|
+
|
|
420
|
+
if not self.is_connected:
|
|
421
|
+
print("cannot get history, not connected")
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
# Prepare the call arguments
|
|
425
|
+
queryParams = validated_params.model_dump()
|
|
426
|
+
|
|
427
|
+
topic = f"history.transformed.app.{self._app_key}.{tablename}"
|
|
428
|
+
try:
|
|
429
|
+
result = await self._connection.call(
|
|
430
|
+
topic,
|
|
431
|
+
args=[queryParams]
|
|
432
|
+
)
|
|
433
|
+
return result
|
|
434
|
+
except Exception as e:
|
|
435
|
+
# Check for specific WAMP errors indicating procedure not available
|
|
436
|
+
error_str = str(e)
|
|
437
|
+
if (hasattr(e, 'error') and
|
|
438
|
+
('no_such_procedure' in str(e.error) or 'runtime_error' in str(e.error))) or \
|
|
439
|
+
'no callee registered for procedure' in error_str:
|
|
440
|
+
print(f"Get history failed: History service procedure '{topic}' not registered in Crossbar")
|
|
441
|
+
print(f" Error type: {getattr(e, 'error', 'Unknown')}")
|
|
442
|
+
print(f" Error details: {e}")
|
|
443
|
+
print(f" This indicates the history service is not available or not properly configured")
|
|
444
|
+
return None
|
|
445
|
+
else:
|
|
446
|
+
print(f"Get history failed: {e}")
|
|
447
|
+
return None
|
|
287
448
|
|
|
288
449
|
async def start(self):
|
|
289
450
|
"""Start the connection and run the main function if provided"""
|
ironflock/types.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type definitions for IronFlock table query functionality.
|
|
3
|
+
Contains Pydantic models for runtime validation of query parameters.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List, Union, Optional, Any
|
|
7
|
+
import warnings
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator, ValidationInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TableQueryCondition(BaseModel):
|
|
12
|
+
"""Individual condition for table queries"""
|
|
13
|
+
field: str = Field(..., min_length=1, description="Field name to filter on")
|
|
14
|
+
operator: str = Field(..., min_length=1, description="Comparison operator (=, !=, >, <, >=, <=, IN, NOT IN, LIKE, etc.)")
|
|
15
|
+
value: Union[str, int, float, bool, List[Union[str, int, float]]] = Field(..., description="Value to compare against")
|
|
16
|
+
|
|
17
|
+
@field_validator('operator')
|
|
18
|
+
@classmethod
|
|
19
|
+
def validate_operator(cls, v: str) -> str:
|
|
20
|
+
"""Validate that operator is from a known set"""
|
|
21
|
+
valid_operators = ['=', '!=', '>', '<', '>=', '<=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'IS NULL', 'IS NOT NULL']
|
|
22
|
+
v_upper = v.upper()
|
|
23
|
+
if v_upper not in valid_operators:
|
|
24
|
+
# Allow the operator but warn about potentially unsafe operators
|
|
25
|
+
warnings.warn(f"Operator '{v}' is not in standard list: {valid_operators}", UserWarning)
|
|
26
|
+
return v
|
|
27
|
+
|
|
28
|
+
@field_validator('value')
|
|
29
|
+
@classmethod
|
|
30
|
+
def validate_value_for_operator(cls, v: Union[str, int, float, bool, List[Union[str, int, float]]], info: ValidationInfo) -> Union[str, int, float, bool, List[Union[str, int, float]]]:
|
|
31
|
+
"""Validate that value type matches the operator"""
|
|
32
|
+
if info.data and 'operator' in info.data:
|
|
33
|
+
operator = info.data['operator'].upper()
|
|
34
|
+
if operator in ('IN', 'NOT IN'):
|
|
35
|
+
# IN operators should have list values
|
|
36
|
+
if not isinstance(v, list):
|
|
37
|
+
raise ValueError(f"Operator '{operator}' requires a list of values")
|
|
38
|
+
if len(v) == 0:
|
|
39
|
+
raise ValueError(f"Operator '{operator}' requires at least one value")
|
|
40
|
+
elif isinstance(v, list):
|
|
41
|
+
# Non-IN operators should not have list values
|
|
42
|
+
raise ValueError(f"Operator '{operator}' does not support list values")
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ISOTimeRange(BaseModel):
|
|
47
|
+
"""ISO time range specification with validation"""
|
|
48
|
+
start: str = Field(..., description="ISO datetime string for range start")
|
|
49
|
+
end: str = Field(..., description="ISO datetime string for range end")
|
|
50
|
+
|
|
51
|
+
@field_validator('start', 'end')
|
|
52
|
+
@classmethod
|
|
53
|
+
def validate_iso_datetime(cls, v: str) -> str:
|
|
54
|
+
"""Validate that the datetime strings are valid ISO format"""
|
|
55
|
+
from datetime import datetime
|
|
56
|
+
try:
|
|
57
|
+
datetime.fromisoformat(v.replace('Z', '+00:00'))
|
|
58
|
+
return v
|
|
59
|
+
except ValueError:
|
|
60
|
+
raise ValueError(f"Invalid ISO datetime format: {v}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SQLFilterAnd(BaseModel):
|
|
64
|
+
"""SQL filter specification for AND conditions with validation"""
|
|
65
|
+
column: str = Field(..., min_length=1, description="Database column name")
|
|
66
|
+
operator: str = Field(..., description="SQL operator (=, >, <, LIKE, etc.)")
|
|
67
|
+
value: Union[str, int, float, bool, List[Union[str, int, float]]] = Field(..., description="Filter value or list of values for IN operator")
|
|
68
|
+
|
|
69
|
+
@field_validator('operator')
|
|
70
|
+
@classmethod
|
|
71
|
+
def validate_operator(cls, v: str) -> str:
|
|
72
|
+
"""Validate that operator is a known SQL operator"""
|
|
73
|
+
valid_operators = {'=', '!=', '<>', '>', '<', '>=', '<=', 'LIKE', 'ILIKE', 'IN', 'NOT IN', 'IS', 'IS NOT'}
|
|
74
|
+
if v.upper() not in valid_operators:
|
|
75
|
+
# Allow the operator but warn about potentially unsafe operators
|
|
76
|
+
warnings.warn(f"Operator '{v}' is not in standard list: {valid_operators}", UserWarning)
|
|
77
|
+
return v
|
|
78
|
+
|
|
79
|
+
@field_validator('value')
|
|
80
|
+
@classmethod
|
|
81
|
+
def validate_value_for_operator(cls, v: Union[str, int, float, bool, List[Union[str, int, float]]], info: ValidationInfo) -> Union[str, int, float, bool, List[Union[str, int, float]]]:
|
|
82
|
+
"""Validate that value type matches the operator"""
|
|
83
|
+
if info.data and 'operator' in info.data:
|
|
84
|
+
operator = info.data['operator'].upper()
|
|
85
|
+
if operator in ('IN', 'NOT IN'):
|
|
86
|
+
# IN operators should have list values
|
|
87
|
+
if not isinstance(v, list):
|
|
88
|
+
raise ValueError(f"Operator '{operator}' requires a list of values")
|
|
89
|
+
if len(v) == 0:
|
|
90
|
+
raise ValueError(f"Operator '{operator}' requires at least one value")
|
|
91
|
+
elif isinstance(v, list):
|
|
92
|
+
# Non-IN operators should not have list values
|
|
93
|
+
raise ValueError(f"Operator '{operator}' does not support list values")
|
|
94
|
+
return v
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TableQueryParams(BaseModel):
|
|
98
|
+
"""Parameters for table history queries with comprehensive validation
|
|
99
|
+
|
|
100
|
+
Equivalent to TypeScript:
|
|
101
|
+
{
|
|
102
|
+
limit: number & tags.Type<"uint32"> & tags.Maximum<10000>;
|
|
103
|
+
offset?: number & tags.Type<"uint32">;
|
|
104
|
+
timeRange?: ISOTimeRange;
|
|
105
|
+
filterAnd?: SQLFilterAnd[];
|
|
106
|
+
}
|
|
107
|
+
"""
|
|
108
|
+
limit: int = Field(..., gt=0, le=10000, description="Maximum number of rows (1-10000)")
|
|
109
|
+
offset: Optional[int] = Field(None, ge=0, description="Offset for pagination (>=0)")
|
|
110
|
+
timeRange: Optional[ISOTimeRange] = Field(None, description="Time range filter")
|
|
111
|
+
filterAnd: Optional[List[SQLFilterAnd]] = Field(None, description="AND conditions for filtering")
|
|
112
|
+
|
|
113
|
+
@field_validator('limit')
|
|
114
|
+
@classmethod
|
|
115
|
+
def validate_limit_uint32(cls, v: int) -> int:
|
|
116
|
+
"""Validate that limit fits in uint32 range"""
|
|
117
|
+
if v > 4294967295: # 2^32 - 1
|
|
118
|
+
raise ValueError("limit exceeds uint32 maximum")
|
|
119
|
+
return v
|
|
120
|
+
|
|
121
|
+
@field_validator('offset')
|
|
122
|
+
@classmethod
|
|
123
|
+
def validate_offset_uint32(cls, v: Optional[int]) -> Optional[int]:
|
|
124
|
+
"""Validate that offset fits in uint32 range"""
|
|
125
|
+
if v is not None and v > 4294967295: # 2^32 - 1
|
|
126
|
+
raise ValueError("offset exceeds uint32 maximum")
|
|
127
|
+
return v
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Additional Pydantic models for other IronFlock functions
|
|
131
|
+
|
|
132
|
+
class PublishParams(BaseModel):
|
|
133
|
+
"""Parameters for publish operations"""
|
|
134
|
+
topic: str = Field(..., min_length=1, description="Topic URI (e.g., 'com.myapp.mytopic1')")
|
|
135
|
+
args: Optional[List[Any]] = Field(default_factory=list, description="Positional arguments")
|
|
136
|
+
kwargs: Optional[dict] = Field(default_factory=dict, description="Keyword arguments")
|
|
137
|
+
|
|
138
|
+
@field_validator('topic')
|
|
139
|
+
@classmethod
|
|
140
|
+
def validate_topic_format(cls, v: str) -> str:
|
|
141
|
+
"""Validate topic follows URI-like format"""
|
|
142
|
+
if not v or not isinstance(v, str):
|
|
143
|
+
raise ValueError("Topic must be a non-empty string")
|
|
144
|
+
# Basic topic format validation (can be enhanced as needed)
|
|
145
|
+
if len(v.strip()) != len(v):
|
|
146
|
+
raise ValueError("Topic cannot have leading/trailing whitespace")
|
|
147
|
+
return v
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class CallParams(BaseModel):
|
|
151
|
+
"""Parameters for remote procedure calls"""
|
|
152
|
+
device_key: str = Field(..., min_length=1, description="Target device key")
|
|
153
|
+
topic: str = Field(..., min_length=1, description="Procedure topic URI")
|
|
154
|
+
args: Optional[List[Any]] = Field(default_factory=list, description="Positional arguments")
|
|
155
|
+
kwargs: Optional[dict] = Field(default_factory=dict, description="Keyword arguments")
|
|
156
|
+
options: Optional[dict] = Field(None, description="Call options")
|
|
157
|
+
|
|
158
|
+
@field_validator('device_key', 'topic')
|
|
159
|
+
@classmethod
|
|
160
|
+
def validate_non_empty_strings(cls, v: str) -> str:
|
|
161
|
+
"""Validate that string fields are non-empty"""
|
|
162
|
+
if not v or not isinstance(v, str):
|
|
163
|
+
raise ValueError("Field must be a non-empty string")
|
|
164
|
+
if len(v.strip()) != len(v):
|
|
165
|
+
raise ValueError("Field cannot have leading/trailing whitespace")
|
|
166
|
+
return v
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class LocationParams(BaseModel):
|
|
170
|
+
"""Parameters for device location updates"""
|
|
171
|
+
longitude: float = Field(..., ge=-180.0, le=180.0, description="Longitude in decimal degrees")
|
|
172
|
+
latitude: float = Field(..., ge=-90.0, le=90.0, description="Latitude in decimal degrees")
|
|
173
|
+
|
|
174
|
+
@field_validator('longitude', 'latitude')
|
|
175
|
+
@classmethod
|
|
176
|
+
def validate_coordinates(cls, v: float) -> float:
|
|
177
|
+
"""Validate coordinate values are finite numbers"""
|
|
178
|
+
if not isinstance(v, (int, float)):
|
|
179
|
+
raise ValueError("Coordinates must be numeric")
|
|
180
|
+
if not (-1000 <= v <= 1000): # Reasonable bounds check
|
|
181
|
+
raise ValueError("Coordinate value seems unrealistic")
|
|
182
|
+
return float(v)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TableParams(BaseModel):
|
|
186
|
+
"""Parameters for table operations"""
|
|
187
|
+
tablename: str = Field(..., min_length=1, description="Table name")
|
|
188
|
+
args: Optional[List[Any]] = Field(default_factory=list, description="Positional arguments")
|
|
189
|
+
kwargs: Optional[dict] = Field(default_factory=dict, description="Keyword arguments")
|
|
190
|
+
|
|
191
|
+
@field_validator('tablename')
|
|
192
|
+
@classmethod
|
|
193
|
+
def validate_tablename(cls, v: str) -> str:
|
|
194
|
+
"""Validate table name format"""
|
|
195
|
+
if not v or not isinstance(v, str):
|
|
196
|
+
raise ValueError("Table name must be a non-empty string")
|
|
197
|
+
|
|
198
|
+
# Remove leading/trailing whitespace
|
|
199
|
+
v = v.strip()
|
|
200
|
+
if not v:
|
|
201
|
+
raise ValueError("Table name cannot be empty or just whitespace")
|
|
202
|
+
|
|
203
|
+
# Basic table name validation
|
|
204
|
+
if not v.replace('_', '').replace('-', '').isalnum():
|
|
205
|
+
raise ValueError("Table name should contain only alphanumeric characters, hyphens, and underscores")
|
|
206
|
+
|
|
207
|
+
if len(v) > 100: # Reasonable length limit
|
|
208
|
+
raise ValueError("Table name too long (max 100 characters)")
|
|
209
|
+
|
|
210
|
+
return v
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SubscriptionParams(BaseModel):
|
|
214
|
+
"""Parameters for subscription operations"""
|
|
215
|
+
topic: str = Field(..., min_length=1, description="Topic URI to subscribe to")
|
|
216
|
+
options: Optional[Union[dict, Any]] = Field(None, description="Subscription options (dict or autobahn options object)")
|
|
217
|
+
|
|
218
|
+
@field_validator('topic')
|
|
219
|
+
@classmethod
|
|
220
|
+
def validate_topic_format(cls, v: str) -> str:
|
|
221
|
+
"""Validate topic follows URI-like format"""
|
|
222
|
+
if not v or not isinstance(v, str):
|
|
223
|
+
raise ValueError("Topic must be a non-empty string")
|
|
224
|
+
if len(v.strip()) != len(v):
|
|
225
|
+
raise ValueError("Topic cannot have leading/trailing whitespace")
|
|
226
|
+
return v
|
|
227
|
+
|
|
228
|
+
@field_validator('options')
|
|
229
|
+
@classmethod
|
|
230
|
+
def validate_options(cls, v: Optional[Union[dict, Any]]) -> Optional[Union[dict, Any]]:
|
|
231
|
+
"""Accept either dict or autobahn options objects"""
|
|
232
|
+
if v is None:
|
|
233
|
+
return v
|
|
234
|
+
# Accept any object that autobahn might pass (SubscribeOptions, etc.)
|
|
235
|
+
return v
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class CrossbarCallParams(BaseModel):
|
|
239
|
+
"""Parameters for low-level Crossbar calls"""
|
|
240
|
+
topic: str = Field(..., min_length=1, description="Topic URI")
|
|
241
|
+
args: Optional[List[Any]] = Field(default_factory=list, description="Positional arguments")
|
|
242
|
+
kwargs: Optional[dict] = Field(default_factory=dict, description="Keyword arguments")
|
|
243
|
+
options: Optional[Union[dict, Any]] = Field(None, description="Call options (dict or autobahn options object)")
|
|
244
|
+
|
|
245
|
+
@field_validator('options')
|
|
246
|
+
@classmethod
|
|
247
|
+
def validate_options(cls, v: Optional[Union[dict, Any]]) -> Optional[Union[dict, Any]]:
|
|
248
|
+
"""Accept either dict or autobahn options objects"""
|
|
249
|
+
if v is None:
|
|
250
|
+
return v
|
|
251
|
+
# Accept any object that autobahn might pass (PublishOptions, CallOptions, etc.)
|
|
252
|
+
return v
|
|
253
|
+
|
|
254
|
+
@field_validator('topic')
|
|
255
|
+
@classmethod
|
|
256
|
+
def validate_topic_format(cls, v: str) -> str:
|
|
257
|
+
"""Validate topic follows URI-like format"""
|
|
258
|
+
if not v or not isinstance(v, str):
|
|
259
|
+
raise ValueError("Topic must be a non-empty string")
|
|
260
|
+
if len(v.strip()) != len(v):
|
|
261
|
+
raise ValueError("Topic cannot have leading/trailing whitespace")
|
|
262
|
+
return v
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ironflock
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: IronFlock Python SDK for connecting to the IronFlock Platform
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -13,6 +13,7 @@ Provides-Extra: dev
|
|
|
13
13
|
Provides-Extra: docs
|
|
14
14
|
Requires-Dist: autobahn[asyncio,serialization] (==24.4.2)
|
|
15
15
|
Requires-Dist: mypy ; extra == "dev"
|
|
16
|
+
Requires-Dist: pydantic (>=2.0.0)
|
|
16
17
|
Requires-Dist: pydantic ; extra == "dev"
|
|
17
18
|
Requires-Dist: pytest ; extra == "dev"
|
|
18
19
|
Requires-Dist: ruff ; extra == "dev"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ironflock/CrossbarConnection.py,sha256=JLMmmnlv3I0Mohz6XdqyQ8-M_qPo3zs_hyIBthFvcTQ,13976
|
|
2
|
+
ironflock/__init__.py,sha256=B2umGnBHiXWzRyMj4a69OETTOdG2A3r5fy8aLVCo-pk,203
|
|
3
|
+
ironflock/ironflock.py,sha256=-UgXRIrgYKCzZIo-i8kYpaPXw8ZzARY2KxlBYfQoOBQ,19137
|
|
4
|
+
ironflock/types.py,sha256=yync91abyrI1fqbmczW5My7Skx7-8soENGHK2JfaEz0,11962
|
|
5
|
+
ironflock-1.3.2.dist-info/METADATA,sha256=CanKlOegj3I_OoK2NnBCgmxTbeqVfK6D9xXJi4k8xHc,3459
|
|
6
|
+
ironflock-1.3.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
7
|
+
ironflock-1.3.2.dist-info/licenses/LICENSE,sha256=GpUKjPB381nmkbBIdX74vxXhsNZaNpngTOciss39Pjk,1073
|
|
8
|
+
ironflock-1.3.2.dist-info/RECORD,,
|
ironflock-1.3.0.dist-info/RECORD
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
ironflock/CrossbarConnection.py,sha256=kWoWQ8TqZORq5XhuL3-c22yIFBzGMpRbCZpztKPMNyQ,12802
|
|
2
|
-
ironflock/__init__.py,sha256=hciT8PV9Jj98wAiZlF9U39wBy6l1DmrI50YaYOnC7qU,203
|
|
3
|
-
ironflock/ironflock.py,sha256=0FLzGMHL83SnFOobKBqL4tcE-6430nASGwn4gnH1LXc,12019
|
|
4
|
-
ironflock-1.3.0.dist-info/METADATA,sha256=dqwaM0pGgwiXSuWQ6zjfqkwHqkjJYgtPR4Xpbzy7do4,3425
|
|
5
|
-
ironflock-1.3.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
6
|
-
ironflock-1.3.0.dist-info/licenses/LICENSE,sha256=GpUKjPB381nmkbBIdX74vxXhsNZaNpngTOciss39Pjk,1073
|
|
7
|
-
ironflock-1.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|