ironflock 1.3.0__tar.gz → 1.3.2__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.0
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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ironflock"
3
- version = "1.3.0"
3
+ version = "1.3.2"
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,8 @@ classifiers = [
13
13
  "Intended Audience :: Developers",
14
14
  ]
15
15
  dependencies = [
16
- "autobahn[asyncio,serialization]==24.4.2"
16
+ "autobahn[asyncio,serialization]==24.4.2",
17
+ "pydantic>=2.0.0"
17
18
  ]
18
19
 
19
20
  [project.urls]
@@ -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
- args = args or []
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
- args = args or []
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:
@@ -7,4 +7,4 @@ __all__ = [
7
7
  "Stage"
8
8
  ]
9
9
 
10
- __version__ = "1.3.0"
10
+ __version__ = "1.3.2"
@@ -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=swarm_key,
78
- app_key=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=list(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": long,
143
- "lat": 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 tablename:
272
- raise Exception("Tablename must not be None or empty string!")
334
+ if not self._swarm_key:
335
+ raise Exception("SWARM_KEY not set in environment variables!")
273
336
 
274
- swarm_key = os.environ.get("SWARM_KEY")
275
- app_key = os.environ.get("APP_KEY")
337
+ if not self._app_key:
338
+ raise Exception("APP_KEY not set in environment variables!")
276
339
 
277
- if swarm_key is None:
278
- raise Exception("Environment variable SWARM_KEY not set!")
340
+ topic = f"{self._swarm_key}.{self._app_key}.{params.tablename}"
279
341
 
280
- if app_key is None:
281
- raise Exception("Environment variable APP_KEY not set!")
342
+ pub = await self.publish(topic, *params.args, **params.kwargs)
343
+ return pub
282
344
 
283
- topic = f"{swarm_key}.{app_key}.{tablename}"
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
- pub = await self.publish(topic, *args, **kwargs)
286
- return pub
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"""
@@ -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
File without changes
File without changes