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.
- triggerware-0.1.0/PKG-INFO +12 -0
- triggerware-0.1.0/README.md +1 -0
- triggerware-0.1.0/pyproject.toml +15 -0
- triggerware-0.1.0/src/triggerware/__init__.py +6 -0
- triggerware-0.1.0/src/triggerware/_external_connector.old +130 -0
- triggerware-0.1.0/src/triggerware/_interfaces.py +26 -0
- triggerware-0.1.0/src/triggerware/_queries.py +403 -0
- triggerware-0.1.0/src/triggerware/_result_set.py +86 -0
- triggerware-0.1.0/src/triggerware/_subscriptions.py +232 -0
- triggerware-0.1.0/src/triggerware/_triggerware_client.py +115 -0
- triggerware-0.1.0/src/triggerware/_types.py +103 -0
|
@@ -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,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
|
+
]
|