triggerware 0.1.0__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.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: triggerware
3
+ Version: 0.1.0
4
+ Summary:
5
+ Author: godofavacyn
6
+ Author-email: godofavacyn <aidenmeyer@mailbox.org>
7
+ Requires-Dist: jayson-rpc
8
+ Requires-Dist: sphinx
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # python-client
@@ -0,0 +1 @@
1
+ # python-client
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "triggerware"
3
+ version = "0.1.0"
4
+ description = ""
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ authors = [{name = "godofavacyn", email = "aidenmeyer@mailbox.org"}]
8
+ dependencies = [
9
+ "jayson-rpc",
10
+ "sphinx"
11
+ ]
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.10.0,<0.11.0"]
15
+ build-backend = "uv_build"
@@ -0,0 +1,6 @@
1
+ from triggerware._triggerware_client import *
2
+ from triggerware._interfaces import *
3
+ from triggerware._queries import *
4
+ from triggerware._subscriptions import *
5
+ from triggerware._result_set import *
6
+ from triggerware._types import *
@@ -0,0 +1,130 @@
1
+ from typing import Any, TYPE_CHECKING, Callable
2
+ import threading
3
+ import inspect
4
+ import importlib
5
+
6
+ if TYPE_CHECKING:
7
+ import triggerware as tw
8
+
9
+ sql_types = {
10
+ "str": "casesensitive",
11
+ "int": "int",
12
+ }
13
+
14
+
15
+ class ConnectorManager:
16
+ def __init__(
17
+ self,
18
+ tw_client: "tw.TriggerwareClient",
19
+ external_address: str,
20
+ external_port: int,
21
+ ) -> None:
22
+ self._client: "tw.TriggerwareClient" = tw_client
23
+ self._address = external_address
24
+ self._port = external_port
25
+ self._lock = threading.Lock()
26
+ self._conn_names: set[str] = set()
27
+ self._gen_functions: dict[str, Callable[..., Any]] = {}
28
+ threading.Thread(target=self._run, daemon=True).start()
29
+
30
+ def create_schema(self, schema: str):
31
+ params = {"name": schema}
32
+ _ = self._client.json_rpc.call("create-sql-schema", params)
33
+
34
+ def add_connector(self, connector: "Connector"):
35
+ generators: list[dict] = []
36
+ with self._lock:
37
+ if connector.name in self._conn_names:
38
+ raise ValueError(
39
+ f"Connector with name {connector.name} already exists."
40
+ )
41
+ self._conn_names.add(connector.name)
42
+ for gen_name, generator in connector.generators.items():
43
+ generators.append(
44
+ {
45
+ "name": gen_name,
46
+ "cost": generator.cost,
47
+ "inputs": generator.inputs,
48
+ }
49
+ )
50
+ self._gen_functions[gen_name] = generator.func
51
+ params = {
52
+ "columns": connector.columns,
53
+ "generator": generators,
54
+ "host": self._address,
55
+ "port": self._port,
56
+ "schema": connector.schema,
57
+ "name": connector.name,
58
+ }
59
+ _ = self._client.json_rpc.call("define-external-jsonrpc-table", params)
60
+
61
+ def _run(self):
62
+ # for conn in TcpStreamConnection.serve(self._address, self._port):
63
+ async def handler(conn: JsonRpcStreamConnection):
64
+ @conn.method()
65
+ def generate(
66
+ inputs: list[Any] = [],
67
+ name: str = "",
68
+ limit=None,
69
+ notify_timelimit=None,
70
+ ):
71
+ with self._lock:
72
+ if name in self._gen_functions:
73
+ return self._gen_functions[name](*inputs)
74
+ else:
75
+ raise ValueError(f"'{name}' not found in connectors.")
76
+ await conn.wait_closed()
77
+
78
+ async def close(self):
79
+ with self._lock:
80
+ await self._client.close()
81
+
82
+ @property
83
+ def is_closed(self) -> bool:
84
+ with self._lock:
85
+ return self._client.is_closed
86
+
87
+
88
+ class Generator:
89
+ def __init__(
90
+ self, name: str, func: Callable[..., Any], inputs: list[str], cost: int
91
+ ):
92
+ self.name = name
93
+ self.func = func
94
+ self.inputs = inputs
95
+ self.cost = cost
96
+
97
+
98
+ class Connector:
99
+ def __init__(
100
+ self,
101
+ *,
102
+ name: str,
103
+ columns: list[tuple[str, str | type]],
104
+ schema: str = "DEMO",
105
+ ):
106
+ self.name = name
107
+ self.schema = schema
108
+ try:
109
+ type_name = lambda ty: (
110
+ sql_types[ty.__name__] if isinstance(ty, type) else ty
111
+ )
112
+ self.columns = [[name, type_name(ty)] for name, ty in columns]
113
+ except KeyError as e:
114
+ raise TypeError(f"Unsupported type in columns: {e}")
115
+ self.generators: dict[str, Generator] = {}
116
+ self.generator_count = 0
117
+
118
+ def generate(self, cost: int = 1):
119
+ def decorator(func: Callable[..., Any]):
120
+ gen_name = f"{self.name}__{self.generator_count}"
121
+ inputs = [
122
+ param.name for param in inspect.signature(func).parameters.values()
123
+ ]
124
+ self.generators[gen_name] = Generator(gen_name, func, inputs, cost)
125
+ self.generator_count += 1
126
+ return func
127
+
128
+ return decorator
129
+
130
+ __all__ = ["ConnectorManager", "Connector", "Generator"]
@@ -0,0 +1,26 @@
1
+ from typing import TYPE_CHECKING
2
+ if TYPE_CHECKING:
3
+ import triggerware as tw
4
+
5
+ class TriggerwareObject:
6
+ """Anything that stores a connection to a triggerware client. Most of these objects have a handle,
7
+ but it is useful to represent certain handleless things, such as Views, as triggerware objects
8
+ since they need direct access to the triggerware client.
9
+ """
10
+ client:"tw.TriggerwareClient"
11
+ handle: int | None = None
12
+
13
+
14
+ class ResourceRestricted:
15
+ """Anything that requires resource limits, such as queries that need to be executed."""
16
+ row_limit: int | None = None
17
+ timeout: float | None = None
18
+
19
+
20
+ class Query:
21
+ """Anything with a query, language, and schema."""
22
+ query: str
23
+ language: str
24
+ schema: str
25
+
26
+ __all__ = ["TriggerwareObject", "ResourceRestricted", "Query"]
@@ -0,0 +1,403 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Callable, TYPE_CHECKING, Self
3
+
4
+ from triggerware._types import InvalidQueryException, PreparedQueryException, PolledQueryControlParameters
5
+
6
+ if TYPE_CHECKING:
7
+ import triggerware as tw
8
+
9
+ from ._interfaces import Query, TriggerwareObject, ResourceRestricted
10
+ from jayson_rpc import InternalErrorException, ServerErrorException, JsonRpcException
11
+ from ._result_set import ResultSet
12
+
13
+
14
+ class QueryRestriction(ResourceRestricted):
15
+ """A simple query restriction, supporting row limits and time limits."""
16
+
17
+ def __init__(self, row_limit: int | None = None, timeout: float | None = None):
18
+ self.row_limit = row_limit
19
+ self.timeout = timeout
20
+
21
+
22
+ class FolQuery(Query):
23
+ """A query written in FOL for execution on the Triggerware server."""
24
+
25
+ def __init__(self, query: str, schema: str = "DEMO", /):
26
+ self.query = query
27
+ self.language = "fol"
28
+ self.schema = schema
29
+
30
+
31
+ class SqlQuery(Query):
32
+ """A query written in SQL for execution on the Triggerware server."""
33
+
34
+ def __init__(self, query: str, schema: str = "DEMO", /):
35
+ self.query = query
36
+ self.language = "sql"
37
+ self.schema = schema
38
+
39
+
40
+ class AbstractQuery[T](Query, ResourceRestricted, TriggerwareObject):
41
+ base_parameters: dict[str, Any]
42
+
43
+ def __init__(
44
+ self,
45
+ client: "tw.TriggerwareClient",
46
+ query: "tw.Query | str",
47
+ restriction: "tw.ResourceRestricted | None" = None,
48
+ /,
49
+ ):
50
+ self.client = client
51
+ if isinstance(query, str):
52
+ query = SqlQuery(query)
53
+
54
+ self.query = query.query
55
+ self.language = query.language
56
+ self.schema = query.schema
57
+ self.base_parameters = {
58
+ "query": self.query,
59
+ "language": self.language,
60
+ "schema": self.schema,
61
+ }
62
+
63
+ if client.default_fetch_size is not None:
64
+ self.row_limit = client.default_fetch_size
65
+ if client.default_timeout is not None:
66
+ self.timeout = client.default_timeout
67
+ if restriction is not None:
68
+ self.row_limit = restriction.row_limit or self.row_limit
69
+ self.timeout = restriction.timeout or self.timeout
70
+ if self.row_limit is not None:
71
+ self.base_parameters["limit"] = self.row_limit
72
+ if self.timeout is not None:
73
+ self.base_parameters["timelimit"] = self.timeout
74
+
75
+ async def validate(self) -> None:
76
+ """Validates the query on the server. Raises an exception if the query is invalid.
77
+
78
+ Raises:
79
+ InvalidQueryException: If the query is invalid.
80
+ InternalErrorException: If an internal error occurs.
81
+ ServerErrorException: If a server error occurs.
82
+ """
83
+
84
+ params = self.base_parameters.copy()
85
+
86
+ try:
87
+ await self.client.json_rpc.call("validate", params)
88
+ except InternalErrorException as e:
89
+ raise e
90
+ except ServerErrorException as e:
91
+ raise e
92
+ except JsonRpcException as e:
93
+ raise InvalidQueryException(e.message)
94
+
95
+
96
+ class View[T](AbstractQuery[T]):
97
+ """
98
+ A simple reusable view of a query. May be executed any number of times. Unlike other queries,
99
+ A View's "handle" is always None - it is only stored locally.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ client: "tw.TriggerwareClient",
105
+ query: "tw.Query | str",
106
+ restriction: "tw.ResourceRestricted | None" = None,
107
+ /,
108
+ ):
109
+ """Initialize a View instance.
110
+
111
+ Args:
112
+ client: The Triggerware client.
113
+ query: The query to execute.
114
+ restriction: Optional restrictions to apply.
115
+ """
116
+ super().__init__(client, query, restriction)
117
+
118
+ async def execute(
119
+ self, restriction: "tw.ResourceRestricted | None" = None
120
+ ) -> "tw.ResultSet[T]":
121
+ """
122
+ Executes the query on the connected triggerware server and returns a result set.
123
+
124
+ Args:
125
+ restriction: Optional restrictions to apply to the query.
126
+
127
+ Raises:
128
+ ServerErrorException: If the server encounters an error while executing the query.
129
+ InvalidQueryException: If the query is invalid or cannot be executed.
130
+ """
131
+
132
+ params = self.base_parameters.copy()
133
+ if restriction is not None:
134
+ if restriction.row_limit is not None:
135
+ params["limit"] = restriction.row_limit
136
+ if restriction.timeout is not None:
137
+ params["timelimit"] = restriction.timeout
138
+
139
+ eq_result = None
140
+ try:
141
+ eq_result = await self.client.json_rpc.call("execute-query", params)
142
+ except ServerErrorException as e:
143
+ raise e
144
+ except JsonRpcException as e:
145
+ raise InvalidQueryException(e.message)
146
+ return ResultSet(self.client, eq_result)
147
+
148
+
149
+ class PreparedQuery[T](AbstractQuery[T]):
150
+ prepared_params: list[Any]
151
+ input_signature_names: list[str]
152
+ input_signature_types: list[str]
153
+ uses_named_params: bool
154
+
155
+ _TYPE_MAP: dict[str, Callable[[str], bool]] = {
156
+ "double": lambda x: isinstance(x, float),
157
+ "integer": lambda x: isinstance(x, int),
158
+ "number": lambda x: isinstance(x, (int, float)),
159
+ "boolean": lambda x: isinstance(x, bool),
160
+ "stringcase": lambda x: isinstance(x, str),
161
+ "stringnocase": lambda x: isinstance(x, str),
162
+ "stringagnostic": lambda x: isinstance(x, str),
163
+ "date": lambda x: isinstance(x, str),
164
+ "time": lambda x: isinstance(x, str),
165
+ "timestamp": lambda x: isinstance(x, str),
166
+ "interval": lambda x: isinstance(x, str),
167
+ }
168
+
169
+ def __init__(
170
+ self,
171
+ client: "tw.TriggerwareClient",
172
+ query: "tw.Query | str",
173
+ restriction: "tw.ResourceRestricted | None" = None,
174
+ /,
175
+ ):
176
+ super().__init__(client, query, restriction)
177
+
178
+ async def register(self) -> Self:
179
+ """Registers this prepared query on the Triggerware server"""
180
+ registration = await self.client.json_rpc.call("prepare-query", self.base_parameters)
181
+ self.prepared_params = [None] * len(registration["inputSignature"])
182
+ self.handle = registration["handle"]
183
+ self.uses_named_params = registration["usesNamedParameters"]
184
+ self.input_signature_names = list(
185
+ map(lambda x: x["attribute"], registration["inputSignature"])
186
+ )
187
+ self.input_signature_types = list(
188
+ map(lambda x: x["type"], registration["inputSignature"])
189
+ )
190
+ if self.handle is not None:
191
+ self.client.register_handle(self.handle)
192
+ return self
193
+
194
+ def set_parameter(self, position: str | int, param: Any) -> None:
195
+ """Sets an unbound value in the query string to a specific value.
196
+
197
+ Args:
198
+ position: The name or position of the parameter to set.
199
+ param: The value to set the parameter to.
200
+ """
201
+
202
+ if not self.uses_named_params and isinstance(position, str):
203
+ raise PreparedQueryException("This query uses positional parameters.")
204
+
205
+ if self.uses_named_params and isinstance(position, int):
206
+ raise PreparedQueryException("This query uses named parameters.")
207
+
208
+ try:
209
+ index = (
210
+ self.input_signature_names.index(position)
211
+ if isinstance(position, str)
212
+ else position
213
+ )
214
+ expected_type = self.input_signature_types[index]
215
+ except Exception:
216
+ raise PreparedQueryException("Invalid parameter name or position.")
217
+
218
+ if self.language == "sql":
219
+ if not PreparedQuery._TYPE_MAP[expected_type](
220
+ param
221
+ ): # Assuming type_map exists
222
+ raise PreparedQueryException(
223
+ f"Expected type {expected_type}, got {type(param).__name__}"
224
+ )
225
+
226
+ self.prepared_params[index] = param
227
+
228
+ def get_parameter(self, position: str | int) -> Any:
229
+ """Gets the value of a parameter in the query string.
230
+
231
+ Args:
232
+ position: The name or position of the parameter to retrieve.
233
+ """
234
+
235
+ if not self.uses_named_params and isinstance(position, str):
236
+ raise PreparedQueryException("This query uses positional parameters.")
237
+
238
+ if self.uses_named_params and isinstance(position, int):
239
+ raise PreparedQueryException("This query uses named parameters.")
240
+
241
+ try:
242
+ index = (
243
+ self.input_signature_names.index(position)
244
+ if isinstance(position, str)
245
+ else position
246
+ )
247
+ parameter = self.prepared_params[index]
248
+ except Exception:
249
+ raise PreparedQueryException("Invalid parameter name or position.")
250
+
251
+ return parameter
252
+
253
+ def clone(self) -> "PreparedQuery[T]":
254
+ """Clones this prepared query with the same parameters."""
255
+ clone = PreparedQuery[T](self.client, self, self)
256
+ clone.prepared_params = self.prepared_params[:]
257
+ return clone
258
+
259
+ async def execute(
260
+ self, restriction: "tw.ResourceRestricted | None" = None
261
+ ) -> "tw.ResultSet[T]":
262
+ """Executes this query on the Triggerware server and returns a ResultSet.
263
+
264
+ Args:
265
+ restriction: Optional restrictions to apply to the query.
266
+ """
267
+
268
+ parameters = {
269
+ "handle": self.handle,
270
+ "inputs": self.prepared_params,
271
+ }
272
+ if restriction:
273
+ if restriction.row_limit is not None:
274
+ parameters["limit"] = restriction.row_limit
275
+ if restriction.timeout is not None:
276
+ parameters["timelimit"] = restriction.timeout
277
+
278
+ eq_result = await self.client.json_rpc.call("create-resultset", parameters)
279
+ return ResultSet[T](self.client, eq_result)
280
+
281
+
282
+ class PolledQuery[T](ABC, AbstractQuery[T]):
283
+ """A PolledQuery is a query that is executed by the TW server on a set schedule.
284
+ As soon as a PolledQuery is created, it is executed by the server, and the response (a set of
285
+ "rows") establishes a "current state" of the query. For each succeeding execution (referred to as
286
+ polling the query):
287
+
288
+ - The new answer is compared with the current state, and the differences are sent to the
289
+ Triggerware client in a notification containing a RowsDelta value.
290
+ - The new answer then becomes the current state to be used for comparison with the result of the
291
+ next poll of the query.
292
+
293
+ Like any other query, a PolledQuery has a query string, a language (FOL or SQL), and a schema.
294
+
295
+ A polling operation may be performed at any time by executing the Poll method.
296
+ Some details of reporting and polling can be configured with a PolledQueryControlParameters
297
+ value that is supplied to the constructor of a PolledQuery.
298
+
299
+ An instantiable subclass of PolledQuery must provide a HandleNotification method to
300
+ handle notifications of changes to the current state. Errors can occur during a polling operation
301
+ (e.g., timeout, inability to contact a data source). When such an error occurs, the TW Server will
302
+ send an "error" notification. An instantiable subclass of PolledQuery may provide a
303
+ HandleError method to handle error notifications.
304
+
305
+ Polling may be terminated when Dispose is called.
306
+
307
+ If a polling operation is ready to start (whether due to its schedule or an explicit poll request)
308
+ and a previous poll of the query has not completed, the poll operation that is ready to start is
309
+ skipped, and an error notification is sent to the client.
310
+ """
311
+
312
+ method_name: str
313
+
314
+ def __init__(
315
+ self,
316
+ client: "tw.TriggerwareClient",
317
+ query: "tw.Query | str",
318
+ schedule: "tw.PolledQuerySchedule",
319
+ restriction: "tw.ResourceRestricted | None" = None,
320
+ controls: "tw.PolledQueryControlParameters | None" = None,
321
+ ) -> None:
322
+ """Initialize a PolledQuery instance.
323
+
324
+ Args:
325
+ client: The Triggerware client.
326
+ query: The query to execute.
327
+ restriction: Optional restrictions to apply.
328
+ controls: Control parameters for polling.
329
+ schedule: The polling schedule.
330
+ """
331
+
332
+ super().__init__(client, query, restriction)
333
+ self.method_name = "poll" + str(self.client._poll_counter) # type: ignore
334
+ self.client._poll_counter += 1 # type: ignore
335
+
336
+ def notify_handler(delta: dict[str, Any] | list[Any], handle: int, timestamp: str, initialized: bool = False) -> None:
337
+ if "delta" in delta:
338
+ delta = delta["delta"] # type: ignore
339
+ self.handle_notification(delta["added"], delta["deleted"]) # type: ignore
340
+ self.client.json_rpc.add_method(name=self.method_name, func=notify_handler)
341
+
342
+ def process_schedule(schedule: "tw.PolledQuerySchedule") -> Any:
343
+ if isinstance(schedule, int):
344
+ return schedule
345
+ if isinstance(schedule, list):
346
+ return [process_schedule(x) for x in schedule]
347
+
348
+ schedule.validate()
349
+ return schedule.__dict__
350
+
351
+ self.base_parameters["method"] = self.method_name
352
+ self.base_parameters["schedule"] = process_schedule(schedule)
353
+ if not controls:
354
+ controls = PolledQueryControlParameters(True, 'with delta', False)
355
+ self.base_parameters["report-initial"] = controls.report_initial
356
+ self.base_parameters["report-noops"] = controls.report_unchanged
357
+ self.base_parameters["delay-schedule"] = controls.delay
358
+
359
+ async def register(self) -> Self:
360
+ registration = await self.client.json_rpc.call(
361
+ "create-polled-query", self.base_parameters
362
+ )
363
+ self.handle = registration["handle"]
364
+ self.signature = []
365
+ if "signature" in registration:
366
+ self.signature = registration["signature"]
367
+ if self.handle is not None:
368
+ self.client.register_handle(self.handle)
369
+ return self
370
+
371
+
372
+ async def close(self) -> bool:
373
+ """Dispose of this polled query, stopping further polling and notifications."""
374
+ return await self.client.json_rpc.call("close-polled-query", [self.handle])
375
+
376
+ async def poll_now(self) -> None:
377
+ """Perform an on-demand poll of this query (temporarily disregarding the set schedule)."""
378
+ parameters: dict[str, Any] = {"handle": self.handle}
379
+ if self.timeout is not None:
380
+ parameters["timelimit"] = self.timeout
381
+ await self.client.json_rpc.call("poll-now", parameters)
382
+
383
+
384
+ @abstractmethod
385
+ def handle_notification(self, added: list[Any], deleted: list[Any]) -> None:
386
+ """Override this method to handle the polled query's changes in data. The polled query's
387
+ schedule determines when this method will be called.
388
+
389
+ Args:
390
+ added: Rows added since the last poll.
391
+ deleted: Rows deleted since the last poll.
392
+ """
393
+ pass
394
+
395
+ __all__ = [
396
+ "QueryRestriction",
397
+ "FolQuery",
398
+ "SqlQuery",
399
+ "AbstractQuery",
400
+ "View",
401
+ "PreparedQuery",
402
+ "PolledQuery",
403
+ ]
@@ -0,0 +1,86 @@
1
+ from typing import Any, TYPE_CHECKING
2
+ if TYPE_CHECKING:
3
+ import triggerware as tw
4
+
5
+ from ._interfaces import TriggerwareObject, ResourceRestricted
6
+
7
+ class ResultSet[T](TriggerwareObject, ResourceRestricted):
8
+ """Represents a result set from the server after executing a query. Result sets are iterable and
9
+ can fetch new rows using next(resultset), or, for multiple rows, resultset.pull(n). Each 'row'
10
+ is a tuple of values determined by the signature of the executed query.
11
+ """
12
+ cache: list[T]
13
+ cache_idx: int
14
+ exhausted: bool
15
+
16
+ def __init__(
17
+ self,
18
+ client: "tw.TriggerwareClient",
19
+ eq_result: dict[str, Any],
20
+ row_limit: int | None = None,
21
+ timeout: float | None = None,
22
+ ) -> None:
23
+ """Initialize a ResultSet instance.
24
+
25
+ Args:
26
+ client: The Triggerware client.
27
+ eq_result: The result dictionary from the server.
28
+ row_limit: The row limit for fetching results.
29
+ timeout: The timeout for fetching results.
30
+ """
31
+ self.client = client
32
+ self.handle = None if 'handle' not in eq_result else eq_result['handle']
33
+ self.row_limit = row_limit if row_limit is not None else client.default_fetch_size
34
+ self.timeout = timeout if timeout is not None else client.default_timeout
35
+ self.signature = []
36
+ self.cache = []
37
+ self.cache_idx = 0
38
+ self.exhausted = False
39
+ if 'signature' in eq_result:
40
+ self.signature = eq_result['signature']
41
+ if 'batch' in eq_result:
42
+ if 'tuples' in eq_result['batch']:
43
+ self.cache = eq_result['batch']['tuples']
44
+ self.exhausted = self.handle == None
45
+
46
+ def __aiter__(self) -> "ResultSet[T]":
47
+ return self
48
+
49
+ async def __anext__(self) -> T:
50
+ if self.cache_idx >= len(self.cache):
51
+ if self.exhausted:
52
+ raise StopAsyncIteration
53
+
54
+ result = await self.client.json_rpc.call("next-resultset-batch", [self.handle, self.row_limit, self.timeout])
55
+ self.cache = result["batch"]["tuples"]
56
+ self.cache_idx = 0
57
+ self.exhausted = result["batch"]["exhausted"]
58
+
59
+ if not self.cache:
60
+ raise StopAsyncIteration
61
+
62
+ value = self.cache[self.cache_idx]
63
+ self.cache_idx += 1
64
+ return value
65
+
66
+ async def pull(self, n: int) -> list[T]:
67
+ """Fetches the next n rows from the result set.
68
+
69
+ Args:
70
+ n: The number of rows to fetch. Will fetch fewer if the result set is exhausted.
71
+ """
72
+ items = []
73
+ for _ in range(n):
74
+ try:
75
+ items.append(await anext(self))
76
+ except StopIteration:
77
+ break
78
+ return items
79
+
80
+ __all__ = ["ResultSet"]
81
+
82
+
83
+
84
+
85
+
86
+
@@ -0,0 +1,232 @@
1
+ # from abc import ABC, abstractmethod
2
+ #
3
+ # from typing import TYPE_CHECKING
4
+ # if TYPE_CHECKING:
5
+ # import triggerware as tw
6
+ #
7
+ # from ._queries import AbstractQuery
8
+ # from ._interfaces import TriggerwareObject
9
+ # from ._types import SubscriptionException
10
+ # from ._triggerware_client import TriggerwareClient
11
+ #
12
+ # class Subscription[T](ABC, AbstractQuery[T]):
13
+ # """
14
+ # A Subscription represents some future change to the data managed by the TW server about which
15
+ # a client would like to be notified. Once created, this subscription will accept notifications from
16
+ # the server when a change occurs, then call the overridden method handle_notification.
17
+ #
18
+ # By default, subscriptions are **active** when they are created—they are immediately registered
19
+ # with the server and start receiving notifications. This behavior may be changed upon construction,
20
+ # or by calling either the activate or deactivate methods.
21
+ #
22
+ # Subscriptions may either be created by passing in a TriggerwareClient, in which case they
23
+ # are immediately registered with the server, OR by passing in a BatchSubscription. See
24
+ # BatchSubscription documentation for more information.
25
+ # """
26
+ # _batch: "BatchSubscription | None"
27
+ # label: str
28
+ # _active: bool
29
+ #
30
+ # @property
31
+ # def active(self) -> bool:
32
+ # return self._active
33
+ #
34
+ # @property
35
+ # def part_of_batch(self) -> bool:
36
+ # return self._batch is not None
37
+ #
38
+ # def __init__(
39
+ # self,
40
+ # client_or_batch: "tw.TriggerwareClient | tw.BatchSubscription",
41
+ # query: "tw.Query",
42
+ # active: bool = True
43
+ # ):
44
+ # """
45
+ # Initialize a Subscription instance.
46
+ #
47
+ # Args:
48
+ # client_or_batch (tw.TriggerwareClient | tw.BatchSubscription): The client or batch to register with.
49
+ # query (tw.Query): The query to subscribe to.
50
+ # active (bool, optional): Whether the subscription should be active upon creation. Defaults to True.
51
+ # """
52
+ # client = client_or_batch if isinstance(client_or_batch, TriggerwareClient) else client_or_batch.client
53
+ # super().__init__(client, query)
54
+ # self._active = False
55
+ # self._batch = None
56
+ # self.label = "sub" + str(client._sub_counter) # type: ignore
57
+ # client._sub_counter += 1 # type: ignore
58
+ # self.base_parameters["label"] = self.label
59
+ #
60
+ #
61
+ # if isinstance(client_or_batch, TriggerwareClient):
62
+ # if active:
63
+ # self.activate()
64
+ # else:
65
+ # self.add_to_batch(client_or_batch)
66
+ #
67
+ # def activate(self):
68
+ # """
69
+ # Activates the subscription, enabling notifications to be sent from the server.
70
+ #
71
+ # Raises:
72
+ # SubscriptionException: If the subscription is already active or is part of a batch.
73
+ # """
74
+ # from triggerware.types import SubscriptionException
75
+ # if self._batch:
76
+ # raise SubscriptionException("Cannot activate subscription that is part of a batch.")
77
+ #
78
+ # if self._active:
79
+ # raise SubscriptionException("Subscription is already active.")
80
+ #
81
+ # params = {**self.base_parameters, "method": self.label, "combine": False}
82
+ # await self.client.json_rpc.call("subscribe", params)
83
+ # def handler(x):
84
+ # self.handle_notification(x['tuple'])
85
+ # self.client.json_rpc.add_method(name=self.label, func=handler)
86
+ # self._active = True
87
+ #
88
+ # def deactivate(self):
89
+ # """
90
+ # Deactivates the subscription, disabling notifications from the server.
91
+ #
92
+ # Raises:
93
+ # SubscriptionException: If the subscription is already inactive or is part of a batch.
94
+ # """
95
+ # if self._batch:
96
+ # raise SubscriptionException("Cannot deactivate a subscription that is part of a batch.")
97
+ #
98
+ # if not self._active:
99
+ # raise SubscriptionException("Subscription is already inactive.")
100
+ #
101
+ # params = {**self.base_parameters, "method": self.label, "combine": False}
102
+ # self.client.json_rpc.call("unsubscribe", params)
103
+ # self.client.json_rpc.remove_method(self.label)
104
+ # self._active = False
105
+ #
106
+ # def add_to_batch(self, batch: "BatchSubscription"):
107
+ # """
108
+ # Adds the subscription to the provided batch. Alternatively, you may call a batch's
109
+ # add_subscription method.
110
+ #
111
+ # Args:
112
+ # batch (BatchSubscription): The batch to add this subscription to.
113
+ #
114
+ # Raises:
115
+ # SubscriptionException: If the subscription is already active, already part of a batch, or registered with a different client.
116
+ # """
117
+ # if self._active:
118
+ # raise SubscriptionException("Cannot add active subscription to a batch.")
119
+ #
120
+ # if self._batch:
121
+ # raise SubscriptionException("Subscription is already part of another batch.")
122
+ #
123
+ # if not self.client is batch.client:
124
+ # raise SubscriptionException("Subscription and batch registered with different clients.")
125
+ #
126
+ # params = {**self.base_parameters, "method": batch.method_name, "combine": True}
127
+ # self.client.json_rpc.call("subscribe", params)
128
+ # batch._subscriptions[self.label] = self # type: ignore
129
+ # self._batch = batch
130
+ #
131
+ # def remove_from_batch(self):
132
+ # """
133
+ # Removes the subscription from its current batch. Alternatively, you may call a batch's
134
+ # remove_subscription method.
135
+ #
136
+ # Raises:
137
+ # SubscriptionException: If the subscription is not part of a batch.
138
+ # """
139
+ # if not self._batch:
140
+ # raise SubscriptionException("Subscription is not part of a batch.")
141
+ #
142
+ # params = {**self.base_parameters, "method": self._batch.method_name, "combine": True}
143
+ # self.client.json_rpc.call("unsubscribe", params)
144
+ # if self.label in self._batch._subscriptions: # type: ignore
145
+ # del self._batch._subscriptions[self.label] # type: ignore
146
+ # self._batch = None
147
+ #
148
+ # def _handle_notification_from_batch(self, data: list[T]):
149
+ # """
150
+ # Handles notifications from a batch subscription and dispatches them to the handler.
151
+ #
152
+ # Args:
153
+ # data (list[T]): The data received in the notification.
154
+ # """
155
+ # for d in data:
156
+ # self.handle_notification(d)
157
+ #
158
+ # @abstractmethod
159
+ # def handle_notification(self, data: T):
160
+ # """
161
+ # Overload this function in an inheriting class to handle notifications that triggering the
162
+ # query will cause this function to activate.
163
+ #
164
+ # Args:
165
+ # data (T): The data received in the notification.
166
+ # """
167
+ # pass
168
+ #
169
+ #
170
+ # class BatchSubscription(TriggerwareObject):
171
+ # """
172
+ # A BatchSubscription groups one or more Subscription instances. Over time, new instances
173
+ # may be added to the BatchSubscription, and/or existing members may be removed. This is useful
174
+ # because a single transaction of a change in data on the triggerware server may be associated with
175
+ # multiple subscriptions.
176
+ #
177
+ # By grouping these subscriptions, notifications may be properly handled by as many Subscription
178
+ # instances as necessary.
179
+ # """
180
+ #
181
+ # def __init__(self, client: "tw.TriggerwareClient"):
182
+ # """
183
+ # Initialize a BatchSubscription instance.
184
+ #
185
+ # Args:
186
+ # client (tw.TriggerwareClient): The Triggerware client.
187
+ # """
188
+ # self.client = client
189
+ # self.method_name = "batch" + str(client._batch_sub_counter) # type: ignore
190
+ # client._batch_sub_counter += 1 # type: ignore
191
+ # self._subscriptions: dict[str, Subscription] = {}
192
+ #
193
+ # @self.client.json_rpc.method(self.method_name)
194
+ # def notify_handler(message):
195
+ # for match in message['matches']:
196
+ # subscription = self._subscriptions.get(match['label'])
197
+ # if subscription:
198
+ # subscription._handle_notification_from_batch(match['tuples']) # type: ignore
199
+ #
200
+ #
201
+ # def add_subscription(self, subscription: Subscription):
202
+ # """
203
+ # Adds a subscription to the batch. Alternatively, you may call a subscription's
204
+ # add_to_batch method.
205
+ #
206
+ # Args:
207
+ # subscription (Subscription): The subscription to add.
208
+ # """
209
+ # subscription.add_to_batch(self)
210
+ #
211
+ # def remove_subscription(self, subscription: Subscription):
212
+ # """
213
+ # Removes a subscription from the batch. Alternatively, you may call a subscription's
214
+ # remove_from_batch method.
215
+ #
216
+ # Args:
217
+ # subscription (Subscription): The subscription to remove.
218
+ #
219
+ # Raises:
220
+ # SubscriptionException: If the subscription is not part of this batch.
221
+ # """
222
+ # if subscription.label not in self._subscriptions:
223
+ # raise SubscriptionException("Subscription is not part of this batch.")
224
+ #
225
+ # subscription.remove_from_batch()
226
+ #
227
+ # __all__ = ["Subscription", "BatchSubscription"]
228
+ #
229
+ #
230
+ #
231
+ #
232
+ #
@@ -0,0 +1,115 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from jayson_rpc import JsonRpcConnection, JsonRpcStreamConnection, JsonRpcWebSocketConnection
4
+ from ._queries import View
5
+
6
+ if TYPE_CHECKING:
7
+ import triggerware as tw
8
+
9
+ class TriggerwareClient:
10
+ """A TriggerwareClient provides a connection to a triggerware server. A client contains methods for
11
+ issuing a few specific requests that are supported by any Triggerware server. Classes that extend
12
+ TriggerwareClient for specific applications will implement their own application-specific methods
13
+ to make requests that are idiosyncratic to a Triggerware server for that application.
14
+
15
+ A TriggerwareClient can also manage Subscriptions. By subscribing to certain kinds of changes,
16
+ the client arranges to be notified when these changes occur in the data accessible to the server.
17
+ """
18
+
19
+ def __init__(self, json_rpc: JsonRpcConnection):
20
+ self.json_rpc = json_rpc
21
+
22
+ self._batch_sub_counter = 0
23
+ self._sub_counter = 0
24
+ self._poll_counter = 0
25
+ self._handles: list[int] = []
26
+
27
+ self.sql_mode: str = "fol"
28
+ self.default_fol_schema: str | None = None
29
+ self.default_sql_schema: str | None = None
30
+ self.default_fetch_size: int | None = None
31
+ self.default_timeout: float | None = None
32
+
33
+ @classmethod
34
+ async def connect_tcp(cls, host: str, port: int) -> "TriggerwareClient":
35
+ """Connect to a Triggerware server over TCP.
36
+
37
+ Args:
38
+ host: The server host.
39
+ port: The server port.
40
+ """
41
+ json_rpc = await JsonRpcStreamConnection.connect(host, port)
42
+ return cls(json_rpc)
43
+
44
+ @classmethod
45
+ async def connect_ws(cls, instance: str, api_key: str) -> "TriggerwareClient":
46
+ """Connect to a Triggerware server over WebSocket.
47
+
48
+ Args:
49
+ uri: The WebSocket URI to connect to.
50
+ """
51
+ uri = "ws://tw-instance-load-balancer-194323221.us-east-2.elb.amazonaws.com"
52
+ json_rpc = await JsonRpcWebSocketConnection.connect(
53
+ uri,
54
+ headers={
55
+ "TW-Instance": instance,
56
+ "Authorization": f"Bearer {api_key}",
57
+ }
58
+ )
59
+ return cls(json_rpc)
60
+
61
+ async def execute_query(
62
+ self,
63
+ query: "tw.Query | str",
64
+ restriction: "tw.ResourceRestricted | None" = None,
65
+ ) -> "tw.ResultSet":
66
+ """Executes a query on the connected server.
67
+
68
+ Args:
69
+ query: The query to execute.
70
+ restriction: Optional restrictions to apply to the query.
71
+
72
+ Returns:
73
+ tw.ResultSet: The result set from the server.
74
+
75
+ Raises:
76
+ InvalidQueryException: If the query is invalid.
77
+ InternalErrorException: If an internal error occurs.
78
+ ServerErrorException: If a server error occurs.
79
+ """
80
+ view = View(self, query, restriction)
81
+ return await view.execute()
82
+
83
+ async def validate_query(self, query: "tw.Query | str"):
84
+ """Validate a query string on the server. This method will raise an InvalidQueryException if
85
+ the query contains errors.
86
+
87
+ Args:
88
+ query: The query to validate.
89
+
90
+ Raises:
91
+ InvalidQueryException: If the query is invalid.
92
+ InternalErrorException: If an internal error occurs.
93
+ ServerErrorException: If a server error occurs.
94
+ """
95
+ return await View(self, query).validate()
96
+
97
+ async def close(self):
98
+ """
99
+ Closes the connection to the server.
100
+ """
101
+ await self.json_rpc.close()
102
+
103
+ @property
104
+ def is_closed(self) -> bool:
105
+ return self.json_rpc.is_closed
106
+
107
+ def register_handle(self, handle: int):
108
+ """Register a handle with this client. Not meant for external use.
109
+
110
+ Args:
111
+ handle (int): The handle to register.
112
+ """
113
+ self._handles.append(handle)
114
+
115
+ __all__ = ["TriggerwareClient"]
@@ -0,0 +1,103 @@
1
+ import re
2
+ from typing import Literal
3
+
4
+
5
+ class TriggerwareClientException(Exception):
6
+ def __init__(self, message: str):
7
+ super().__init__(message)
8
+ self.message = message
9
+ pass
10
+
11
+
12
+ class InvalidQueryException(TriggerwareClientException):
13
+ pass
14
+
15
+
16
+ class PolledQueryException(TriggerwareClientException):
17
+ pass
18
+
19
+
20
+ class PreparedQueryException(TriggerwareClientException):
21
+ pass
22
+
23
+
24
+ class SubscriptionException(TriggerwareClientException):
25
+ pass
26
+
27
+
28
+ class PolledQueryControlParameters:
29
+ report_unchanged: bool
30
+ report_initial: str
31
+ delay: bool
32
+
33
+ def __init__(
34
+ self,
35
+ report_unchanged: bool = False,
36
+ report_initial: Literal["none"] | Literal["with delta"] | Literal["without delta"] = "none",
37
+ delay: bool = False,
38
+ ):
39
+ self.report_unchanged = report_unchanged
40
+ self.report_initial = report_initial
41
+ self.delay = delay
42
+
43
+
44
+ class PolledQueryCalendarSchedule:
45
+ days: str
46
+ hours: str
47
+ minutes: str
48
+ months: str
49
+ timezone: str
50
+ weekdays: str
51
+ _TIMEZONE_REGEX = re.compile(r"^[A-Za-z]+(?:_[A-Za-z]+)*(?:/[A-Za-z]+(?:_[A-Za-z]+)*)*$")
52
+
53
+ def __init__(
54
+ self,
55
+ days: str = "*",
56
+ hours: str = "*",
57
+ minutes: str = "*",
58
+ months: str = "*",
59
+ timezone: str = "UTC",
60
+ weekdays: str = "*",
61
+ ):
62
+ self.days = days
63
+ self.hours = hours
64
+ self.minutes = minutes
65
+ self.months = months
66
+ self.timezone = timezone
67
+ self.weekdays = weekdays
68
+
69
+ @staticmethod
70
+ def _validate_time(unit: str, value: str, min: int, max: int):
71
+ if value == "*":
72
+ return
73
+
74
+ parts = [y for x in re.split(",", value) for y in re.split("-", x)]
75
+ for part in parts:
76
+ try:
77
+ parsed = int(part)
78
+ except ValueError:
79
+ raise PolledQueryException(f"Invalid {unit} value: {part}")
80
+ if parsed < min or parsed > max:
81
+ raise PolledQueryException(f"{unit} value out of range: {part}")
82
+
83
+ def validate(self):
84
+ self._validate_time("day", self.days, 1, 31)
85
+ self._validate_time("hour", self.hours, 0, 23)
86
+ self._validate_time("minute", self.minutes, 0, 59)
87
+ self._validate_time("month", self.months, 1, 12)
88
+ self._validate_time("weekday", self.weekdays, 0, 6)
89
+ if not self._TIMEZONE_REGEX.match(self.timezone):
90
+ raise PolledQueryException("Invalid timezone format")
91
+
92
+ type PolledQuerySchedule = int | PolledQueryCalendarSchedule | list[PolledQuerySchedule]
93
+
94
+ __all__ = [
95
+ "TriggerwareClientException",
96
+ "InvalidQueryException",
97
+ "PolledQueryException",
98
+ "PreparedQueryException",
99
+ "SubscriptionException",
100
+ "PolledQueryControlParameters",
101
+ "PolledQueryCalendarSchedule",
102
+ "PolledQuerySchedule",
103
+ ]