rtbot-sql 0.1.14__cp314-cp314-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtbot_sql/__init__.py +52 -0
- rtbot_sql/_rtbot_sql_native.so +0 -0
- rtbot_sql/catalog.py +247 -0
- rtbot_sql/compiler.py +45 -0
- rtbot_sql/formatter.py +73 -0
- rtbot_sql/jupyter.py +42 -0
- rtbot_sql/pipeline_runner.py +98 -0
- rtbot_sql/runtime.py +592 -0
- rtbot_sql/stream_store.py +100 -0
- rtbot_sql-0.1.14.dist-info/METADATA +65 -0
- rtbot_sql-0.1.14.dist-info/RECORD +12 -0
- rtbot_sql-0.1.14.dist-info/WHEEL +4 -0
rtbot_sql/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""rtbot-sql Python runtime."""
|
|
2
|
+
|
|
3
|
+
from .catalog import ColumnDef, InMemoryCatalog, StreamSchema, TableSchema, ViewMeta
|
|
4
|
+
from .compiler import compile_sql, native, validate_sql
|
|
5
|
+
from .formatter import normalize_time_column
|
|
6
|
+
from .pipeline_runner import InputMessage, LocalPipelineRunner, PipelineOutput
|
|
7
|
+
from .runtime import InsertResult, RtBotSql, SqlError
|
|
8
|
+
from .stream_store import InMemoryStreamStore, Message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_default = RtBotSql()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def execute(sql: str):
|
|
15
|
+
return _default.execute(sql)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def insert_dataframe(stream_name: str, dataframe):
|
|
19
|
+
return _default.insert_dataframe(stream_name, dataframe)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def show_graph(view_name: str):
|
|
23
|
+
return _default.show_graph(view_name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def explain(sql: str):
|
|
27
|
+
return _default.explain(sql)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ColumnDef",
|
|
32
|
+
"StreamSchema",
|
|
33
|
+
"ViewMeta",
|
|
34
|
+
"TableSchema",
|
|
35
|
+
"InMemoryCatalog",
|
|
36
|
+
"InMemoryStreamStore",
|
|
37
|
+
"Message",
|
|
38
|
+
"InputMessage",
|
|
39
|
+
"PipelineOutput",
|
|
40
|
+
"LocalPipelineRunner",
|
|
41
|
+
"InsertResult",
|
|
42
|
+
"RtBotSql",
|
|
43
|
+
"SqlError",
|
|
44
|
+
"normalize_time_column",
|
|
45
|
+
"compile_sql",
|
|
46
|
+
"validate_sql",
|
|
47
|
+
"native",
|
|
48
|
+
"execute",
|
|
49
|
+
"insert_dataframe",
|
|
50
|
+
"show_graph",
|
|
51
|
+
"explain",
|
|
52
|
+
]
|
|
Binary file
|
rtbot_sql/catalog.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""In-memory catalog for the Python runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
from .compiler import native
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ColumnDef:
|
|
13
|
+
name: str
|
|
14
|
+
index: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class StreamSchema:
|
|
19
|
+
name: str
|
|
20
|
+
columns: List[ColumnDef] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ViewMeta:
|
|
25
|
+
name: str
|
|
26
|
+
entity_type: Any
|
|
27
|
+
view_type: Any
|
|
28
|
+
field_map: Dict[str, int] = field(default_factory=dict)
|
|
29
|
+
source_streams: List[str] = field(default_factory=list)
|
|
30
|
+
program_json: str = ""
|
|
31
|
+
output_stream: str = ""
|
|
32
|
+
per_key_prefix: str = ""
|
|
33
|
+
known_keys: List[float] = field(default_factory=list)
|
|
34
|
+
key_index: int = -1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class TableSchema:
|
|
39
|
+
name: str
|
|
40
|
+
columns: List[ColumnDef] = field(default_factory=list)
|
|
41
|
+
changelog_stream: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _to_column_def(obj: Any) -> ColumnDef:
|
|
45
|
+
if isinstance(obj, ColumnDef):
|
|
46
|
+
return ColumnDef(obj.name, obj.index)
|
|
47
|
+
if isinstance(obj, native.ColumnDef):
|
|
48
|
+
return ColumnDef(obj.name, int(obj.index))
|
|
49
|
+
if isinstance(obj, dict):
|
|
50
|
+
return ColumnDef(str(obj["name"]), int(obj["index"]))
|
|
51
|
+
raise TypeError(f"Unsupported column definition: {type(obj)!r}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _to_stream_schema(name: str, obj: Any) -> StreamSchema:
|
|
55
|
+
if isinstance(obj, StreamSchema):
|
|
56
|
+
return StreamSchema(name=name or obj.name, columns=[_to_column_def(c) for c in obj.columns])
|
|
57
|
+
if isinstance(obj, native.StreamSchema):
|
|
58
|
+
return StreamSchema(name=name or obj.name, columns=[_to_column_def(c) for c in obj.columns])
|
|
59
|
+
if isinstance(obj, dict):
|
|
60
|
+
cols = [_to_column_def(c) for c in obj.get("columns", [])]
|
|
61
|
+
return StreamSchema(name=name or str(obj.get("name", name)), columns=cols)
|
|
62
|
+
raise TypeError(f"Unsupported stream schema: {type(obj)!r}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _to_view_meta(name: str, obj: Any) -> ViewMeta:
|
|
66
|
+
if isinstance(obj, ViewMeta):
|
|
67
|
+
return ViewMeta(
|
|
68
|
+
name=name or obj.name,
|
|
69
|
+
entity_type=obj.entity_type,
|
|
70
|
+
view_type=obj.view_type,
|
|
71
|
+
field_map=dict(obj.field_map),
|
|
72
|
+
source_streams=list(obj.source_streams),
|
|
73
|
+
program_json=obj.program_json,
|
|
74
|
+
output_stream=obj.output_stream,
|
|
75
|
+
per_key_prefix=obj.per_key_prefix,
|
|
76
|
+
known_keys=list(obj.known_keys),
|
|
77
|
+
key_index=int(obj.key_index),
|
|
78
|
+
)
|
|
79
|
+
if isinstance(obj, native.ViewMeta):
|
|
80
|
+
return ViewMeta(
|
|
81
|
+
name=name or obj.name,
|
|
82
|
+
entity_type=obj.entity_type,
|
|
83
|
+
view_type=obj.view_type,
|
|
84
|
+
field_map=dict(obj.field_map),
|
|
85
|
+
source_streams=list(obj.source_streams),
|
|
86
|
+
program_json=obj.program_json,
|
|
87
|
+
output_stream=obj.output_stream,
|
|
88
|
+
per_key_prefix=obj.per_key_prefix,
|
|
89
|
+
known_keys=list(obj.known_keys),
|
|
90
|
+
key_index=int(obj.key_index),
|
|
91
|
+
)
|
|
92
|
+
if isinstance(obj, dict):
|
|
93
|
+
return ViewMeta(
|
|
94
|
+
name=name or str(obj.get("name", name)),
|
|
95
|
+
entity_type=obj.get("entity_type", native.EntityType.VIEW),
|
|
96
|
+
view_type=obj.get("view_type", native.ViewType.SCALAR),
|
|
97
|
+
field_map=dict(obj.get("field_map", {})),
|
|
98
|
+
source_streams=list(obj.get("source_streams", [])),
|
|
99
|
+
program_json=str(obj.get("program_json", "")),
|
|
100
|
+
output_stream=str(obj.get("output_stream", "")),
|
|
101
|
+
per_key_prefix=str(obj.get("per_key_prefix", "")),
|
|
102
|
+
known_keys=[float(v) for v in obj.get("known_keys", [])],
|
|
103
|
+
key_index=int(obj.get("key_index", -1)),
|
|
104
|
+
)
|
|
105
|
+
raise TypeError(f"Unsupported view metadata: {type(obj)!r}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _to_table_schema(name: str, obj: Any) -> TableSchema:
|
|
109
|
+
if isinstance(obj, TableSchema):
|
|
110
|
+
return TableSchema(
|
|
111
|
+
name=name or obj.name,
|
|
112
|
+
columns=[_to_column_def(c) for c in obj.columns],
|
|
113
|
+
changelog_stream=obj.changelog_stream,
|
|
114
|
+
)
|
|
115
|
+
if isinstance(obj, native.TableSchema):
|
|
116
|
+
return TableSchema(
|
|
117
|
+
name=name or obj.name,
|
|
118
|
+
columns=[_to_column_def(c) for c in obj.columns],
|
|
119
|
+
changelog_stream=obj.changelog_stream,
|
|
120
|
+
)
|
|
121
|
+
if isinstance(obj, dict):
|
|
122
|
+
return TableSchema(
|
|
123
|
+
name=name or str(obj.get("name", name)),
|
|
124
|
+
columns=[_to_column_def(c) for c in obj.get("columns", [])],
|
|
125
|
+
changelog_stream=str(obj.get("changelog_stream", "")),
|
|
126
|
+
)
|
|
127
|
+
raise TypeError(f"Unsupported table schema: {type(obj)!r}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _column_to_native(col: ColumnDef) -> native.ColumnDef:
|
|
131
|
+
return native.ColumnDef(col.name, int(col.index))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _stream_to_native(schema: StreamSchema) -> native.StreamSchema:
|
|
135
|
+
out = native.StreamSchema()
|
|
136
|
+
out.name = schema.name
|
|
137
|
+
out.columns = [_column_to_native(c) for c in schema.columns]
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _view_to_native(meta: ViewMeta) -> native.ViewMeta:
|
|
142
|
+
out = native.ViewMeta()
|
|
143
|
+
out.name = meta.name
|
|
144
|
+
out.entity_type = meta.entity_type
|
|
145
|
+
out.view_type = meta.view_type
|
|
146
|
+
out.field_map = dict(meta.field_map)
|
|
147
|
+
out.source_streams = list(meta.source_streams)
|
|
148
|
+
out.program_json = meta.program_json
|
|
149
|
+
out.output_stream = meta.output_stream
|
|
150
|
+
out.per_key_prefix = meta.per_key_prefix
|
|
151
|
+
out.known_keys = [float(v) for v in meta.known_keys]
|
|
152
|
+
out.key_index = int(meta.key_index)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _table_to_native(schema: TableSchema) -> native.TableSchema:
|
|
157
|
+
out = native.TableSchema()
|
|
158
|
+
out.name = schema.name
|
|
159
|
+
out.columns = [_column_to_native(c) for c in schema.columns]
|
|
160
|
+
out.changelog_stream = schema.changelog_stream
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class InMemoryCatalog:
|
|
165
|
+
def __init__(self) -> None:
|
|
166
|
+
self._streams: Dict[str, StreamSchema] = {}
|
|
167
|
+
self._views: Dict[str, ViewMeta] = {}
|
|
168
|
+
self._tables: Dict[str, TableSchema] = {}
|
|
169
|
+
|
|
170
|
+
def register_stream(self, name: str, schema: Any) -> None:
|
|
171
|
+
self._streams[name] = _to_stream_schema(name, schema)
|
|
172
|
+
|
|
173
|
+
def register_view(self, name: str, meta: Any) -> None:
|
|
174
|
+
self._views[name] = _to_view_meta(name, meta)
|
|
175
|
+
|
|
176
|
+
def register_table(self, name: str, schema: Any) -> None:
|
|
177
|
+
self._tables[name] = _to_table_schema(name, schema)
|
|
178
|
+
|
|
179
|
+
def lookup_stream(self, name: str) -> Optional[StreamSchema]:
|
|
180
|
+
return self._streams.get(name)
|
|
181
|
+
|
|
182
|
+
def lookup_view(self, name: str) -> Optional[ViewMeta]:
|
|
183
|
+
return self._views.get(name)
|
|
184
|
+
|
|
185
|
+
def lookup_table(self, name: str) -> Optional[TableSchema]:
|
|
186
|
+
return self._tables.get(name)
|
|
187
|
+
|
|
188
|
+
def resolve_entity(self, name: str) -> Optional[Any]:
|
|
189
|
+
if name in self._streams:
|
|
190
|
+
return native.EntityType.STREAM
|
|
191
|
+
if name in self._views:
|
|
192
|
+
return self._views[name].entity_type
|
|
193
|
+
if name in self._tables:
|
|
194
|
+
return native.EntityType.TABLE
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
def snapshot(self) -> native.CatalogSnapshot:
|
|
198
|
+
snap = native.CatalogSnapshot()
|
|
199
|
+
snap.streams = {name: _stream_to_native(schema) for name, schema in self._streams.items()}
|
|
200
|
+
snap.views = {name: _view_to_native(meta) for name, meta in self._views.items()}
|
|
201
|
+
snap.tables = {name: _table_to_native(schema) for name, schema in self._tables.items()}
|
|
202
|
+
return snap
|
|
203
|
+
|
|
204
|
+
def add_key(self, view_name: str, key: float) -> None:
|
|
205
|
+
view = self._views.get(view_name)
|
|
206
|
+
if view is None:
|
|
207
|
+
return
|
|
208
|
+
key_value = float(key)
|
|
209
|
+
if key_value not in view.known_keys:
|
|
210
|
+
view.known_keys.append(key_value)
|
|
211
|
+
|
|
212
|
+
def get_known_keys(self, view_name: str) -> set:
|
|
213
|
+
view = self._views.get(view_name)
|
|
214
|
+
if view is None:
|
|
215
|
+
return set()
|
|
216
|
+
return set(view.known_keys)
|
|
217
|
+
|
|
218
|
+
def drop_stream(self, name: str) -> None:
|
|
219
|
+
self._streams.pop(name, None)
|
|
220
|
+
|
|
221
|
+
def drop_view(self, name: str) -> None:
|
|
222
|
+
self._views.pop(name, None)
|
|
223
|
+
|
|
224
|
+
def drop_table(self, name: str) -> None:
|
|
225
|
+
self._tables.pop(name, None)
|
|
226
|
+
|
|
227
|
+
def drop(self, name: str) -> bool:
|
|
228
|
+
removed = False
|
|
229
|
+
if name in self._streams:
|
|
230
|
+
self._streams.pop(name, None)
|
|
231
|
+
removed = True
|
|
232
|
+
if name in self._views:
|
|
233
|
+
self._views.pop(name, None)
|
|
234
|
+
removed = True
|
|
235
|
+
if name in self._tables:
|
|
236
|
+
self._tables.pop(name, None)
|
|
237
|
+
removed = True
|
|
238
|
+
return removed
|
|
239
|
+
|
|
240
|
+
def streams(self) -> Dict[str, StreamSchema]:
|
|
241
|
+
return dict(self._streams)
|
|
242
|
+
|
|
243
|
+
def views(self) -> Dict[str, ViewMeta]:
|
|
244
|
+
return dict(self._views)
|
|
245
|
+
|
|
246
|
+
def tables(self) -> Dict[str, TableSchema]:
|
|
247
|
+
return dict(self._tables)
|
rtbot_sql/compiler.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Thin wrappers over the native rtbot-sql extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
from importlib.machinery import ExtensionFileLoader, ModuleSpec
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from . import _rtbot_sql_native as native # type: ignore[attr-defined]
|
|
13
|
+
except ImportError: # pragma: no cover - fallback for Bazel runfiles layout
|
|
14
|
+
try:
|
|
15
|
+
import _rtbot_sql_native as native # type: ignore
|
|
16
|
+
except ImportError:
|
|
17
|
+
if not sys.platform.startswith("win"):
|
|
18
|
+
raise
|
|
19
|
+
ext_path = Path(__file__).with_name("_rtbot_sql_native.so")
|
|
20
|
+
if not ext_path.exists():
|
|
21
|
+
raise
|
|
22
|
+
loader = ExtensionFileLoader("rtbot_sql._rtbot_sql_native", str(ext_path))
|
|
23
|
+
spec = ModuleSpec("rtbot_sql._rtbot_sql_native", loader, origin=str(ext_path))
|
|
24
|
+
native = importlib.util.module_from_spec(spec)
|
|
25
|
+
loader.exec_module(native)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compile_sql(sql: str, catalog_snapshot: Any) -> Any:
|
|
29
|
+
return native.compile_sql(sql, catalog_snapshot)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_sql(sql: str) -> dict:
|
|
33
|
+
return native.validate_sql(sql)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compile_select_to_program(sql: str, catalog_snapshot: Any) -> Any:
|
|
37
|
+
"""Compile a SELECT by wrapping it in CREATE MATERIALIZED VIEW.
|
|
38
|
+
|
|
39
|
+
compile_sql() only emits program_json for tier-3 SELECT queries. For tier-2,
|
|
40
|
+
this wrapper forces graph compilation and lets the Python runtime execute the
|
|
41
|
+
query locally through the native pipeline runner.
|
|
42
|
+
"""
|
|
43
|
+
select_sql = sql.strip().rstrip(";")
|
|
44
|
+
wrapped_sql = f"CREATE MATERIALIZED VIEW __rtbot_sql_tmp AS {select_sql}"
|
|
45
|
+
return native.compile_sql(wrapped_sql, catalog_snapshot)
|
rtbot_sql/formatter.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Helpers to format runtime outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import pandas as pd
|
|
9
|
+
_HAS_PANDAS = True
|
|
10
|
+
except Exception: # pragma: no cover - optional dependency
|
|
11
|
+
pd = None
|
|
12
|
+
_HAS_PANDAS = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ordered_columns(field_map: Dict[str, int]) -> List[str]:
|
|
16
|
+
return [name for name, _ in sorted(field_map.items(), key=lambda item: item[1])]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def project_rows(rows: Iterable[Sequence[float]], field_map: Dict[str, int]) -> List[List[float]]:
|
|
20
|
+
ordered = sorted(field_map.items(), key=lambda item: item[1])
|
|
21
|
+
indices = [index for _, index in ordered]
|
|
22
|
+
|
|
23
|
+
projected: List[List[float]] = []
|
|
24
|
+
for row in rows:
|
|
25
|
+
projected.append([
|
|
26
|
+
float(row[idx]) if 0 <= idx < len(row) else 0.0
|
|
27
|
+
for idx in indices
|
|
28
|
+
])
|
|
29
|
+
return projected
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def format_rows(
|
|
33
|
+
rows: Iterable[Sequence[float]],
|
|
34
|
+
field_map: Dict[str, int],
|
|
35
|
+
timestamps: Optional[List[int]] = None,
|
|
36
|
+
time_values: Optional[List[Any]] = None,
|
|
37
|
+
time_column: str = "time",
|
|
38
|
+
as_dataframe: bool = True,
|
|
39
|
+
):
|
|
40
|
+
columns = ordered_columns(field_map)
|
|
41
|
+
normalized = project_rows(rows, field_map)
|
|
42
|
+
|
|
43
|
+
if as_dataframe and _HAS_PANDAS:
|
|
44
|
+
df = pd.DataFrame(normalized, columns=columns)
|
|
45
|
+
if timestamps is not None:
|
|
46
|
+
values = time_values if time_values is not None else timestamps
|
|
47
|
+
df.insert(0, time_column, values)
|
|
48
|
+
# Auto-normalize: rename _time -> time and convert numeric timestamps.
|
|
49
|
+
if '_time' in df.columns and 'time' not in df.columns:
|
|
50
|
+
df = df.rename(columns={'_time': 'time'})
|
|
51
|
+
if pd.api.types.is_numeric_dtype(df['time']):
|
|
52
|
+
df['time'] = pd.to_datetime(df['time'], unit='ms')
|
|
53
|
+
return df
|
|
54
|
+
|
|
55
|
+
result = {
|
|
56
|
+
"columns": columns,
|
|
57
|
+
"rows": normalized,
|
|
58
|
+
}
|
|
59
|
+
if timestamps is not None:
|
|
60
|
+
result["timestamps"] = timestamps
|
|
61
|
+
result["time_column"] = time_column
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_time_column(df, default_unit='ms'):
|
|
66
|
+
if 'time' in df.columns:
|
|
67
|
+
return df
|
|
68
|
+
if '_time' in df.columns:
|
|
69
|
+
out = df.rename(columns={'_time': 'time'}).copy()
|
|
70
|
+
if pd.api.types.is_numeric_dtype(out['time']):
|
|
71
|
+
out['time'] = pd.to_datetime(out['time'], unit=default_unit)
|
|
72
|
+
return out
|
|
73
|
+
raise KeyError("Expected a time column in SQL result ('time' or '_time')")
|
rtbot_sql/jupyter.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Jupyter display helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _safe_node_id(node_id: str) -> str:
|
|
10
|
+
return node_id.replace(":", "_").replace("-", "_")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def program_json_to_mermaid(program_json: str) -> str:
|
|
14
|
+
parsed = json.loads(program_json)
|
|
15
|
+
lines = ["flowchart LR"]
|
|
16
|
+
|
|
17
|
+
operators = parsed.get("operators", [])
|
|
18
|
+
for op in operators:
|
|
19
|
+
op_id = str(op.get("id", "unknown"))
|
|
20
|
+
op_type = str(op.get("type", "Operator"))
|
|
21
|
+
lines.append(f' {_safe_node_id(op_id)}["{op_id}\\n{op_type}"]')
|
|
22
|
+
|
|
23
|
+
for connection in parsed.get("connections", []):
|
|
24
|
+
src = _safe_node_id(str(connection.get("from", "")))
|
|
25
|
+
dst = _safe_node_id(str(connection.get("to", "")))
|
|
26
|
+
from_port = str(connection.get("fromPort", "o1"))
|
|
27
|
+
to_port = str(connection.get("toPort", "i1"))
|
|
28
|
+
lines.append(f' {src} -->|"{from_port}->{to_port}"| {dst}')
|
|
29
|
+
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def show_graph(program_json: str):
|
|
34
|
+
mermaid = program_json_to_mermaid(program_json)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from IPython.display import Markdown, display
|
|
38
|
+
|
|
39
|
+
display(Markdown(f"```mermaid\n{mermaid}\n```"))
|
|
40
|
+
return None
|
|
41
|
+
except Exception: # pragma: no cover - used outside notebooks
|
|
42
|
+
return mermaid
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Local pipeline runner backed by native rtbot::Program execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Iterable, List
|
|
7
|
+
|
|
8
|
+
from .compiler import native
|
|
9
|
+
from .stream_store import Message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class InputMessage:
|
|
14
|
+
timestamp: int
|
|
15
|
+
values: List[float]
|
|
16
|
+
port: str = "i1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PipelineOutput:
|
|
21
|
+
timestamp: int
|
|
22
|
+
values: List[float]
|
|
23
|
+
operator_id: str = ""
|
|
24
|
+
port: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalize_input(item: Any) -> InputMessage:
|
|
28
|
+
if isinstance(item, InputMessage):
|
|
29
|
+
return InputMessage(int(item.timestamp), [float(v) for v in item.values], item.port)
|
|
30
|
+
if isinstance(item, Message):
|
|
31
|
+
return InputMessage(int(item.timestamp), [float(v) for v in item.values], "i1")
|
|
32
|
+
if isinstance(item, dict):
|
|
33
|
+
return InputMessage(
|
|
34
|
+
int(item["timestamp"]),
|
|
35
|
+
[float(v) for v in item["values"]],
|
|
36
|
+
str(item.get("port", "i1")),
|
|
37
|
+
)
|
|
38
|
+
if isinstance(item, (tuple, list)) and len(item) >= 2:
|
|
39
|
+
port = "i1" if len(item) < 3 else str(item[2])
|
|
40
|
+
return InputMessage(int(item[0]), [float(v) for v in item[1]], port)
|
|
41
|
+
raise TypeError(f"Unsupported input message: {type(item)!r}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LocalPipelineRunner:
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._pipelines: Dict[str, native.NativePipeline] = {}
|
|
47
|
+
self._counter = 0
|
|
48
|
+
|
|
49
|
+
def _next_id(self) -> str:
|
|
50
|
+
self._counter += 1
|
|
51
|
+
return f"pipeline_{self._counter}"
|
|
52
|
+
|
|
53
|
+
def deploy(self, program_json: str, _input_streams: Any = None, _output_config: Any = None) -> str:
|
|
54
|
+
pipeline_id = self._next_id()
|
|
55
|
+
self._pipelines[pipeline_id] = native.NativePipeline(program_json)
|
|
56
|
+
return pipeline_id
|
|
57
|
+
|
|
58
|
+
def run_once(self, program_json: str, input_messages: Iterable[Any]) -> List[PipelineOutput]:
|
|
59
|
+
pipeline = native.NativePipeline(program_json)
|
|
60
|
+
return self._run_pipeline(pipeline, input_messages)
|
|
61
|
+
|
|
62
|
+
def feed(
|
|
63
|
+
self,
|
|
64
|
+
pipeline_id: str,
|
|
65
|
+
timestamp: int,
|
|
66
|
+
values: List[float],
|
|
67
|
+
port: str = "i1",
|
|
68
|
+
) -> List[PipelineOutput]:
|
|
69
|
+
pipeline = self._pipelines[pipeline_id]
|
|
70
|
+
native_outputs = pipeline.feed(int(timestamp), [float(v) for v in values], str(port))
|
|
71
|
+
return [
|
|
72
|
+
PipelineOutput(
|
|
73
|
+
timestamp=int(out.timestamp),
|
|
74
|
+
values=[float(v) for v in out.values],
|
|
75
|
+
operator_id=str(out.operator_id),
|
|
76
|
+
port=str(out.port),
|
|
77
|
+
)
|
|
78
|
+
for out in native_outputs
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def destroy(self, pipeline_id: str) -> None:
|
|
82
|
+
self._pipelines.pop(pipeline_id, None)
|
|
83
|
+
|
|
84
|
+
def _run_pipeline(self, pipeline: native.NativePipeline, input_messages: Iterable[Any]) -> List[PipelineOutput]:
|
|
85
|
+
outputs: List[PipelineOutput] = []
|
|
86
|
+
for raw in input_messages:
|
|
87
|
+
msg = _normalize_input(raw)
|
|
88
|
+
native_outputs = pipeline.feed(msg.timestamp, msg.values, msg.port)
|
|
89
|
+
outputs.extend(
|
|
90
|
+
PipelineOutput(
|
|
91
|
+
timestamp=int(out.timestamp),
|
|
92
|
+
values=[float(v) for v in out.values],
|
|
93
|
+
operator_id=str(out.operator_id),
|
|
94
|
+
port=str(out.port),
|
|
95
|
+
)
|
|
96
|
+
for out in native_outputs
|
|
97
|
+
)
|
|
98
|
+
return outputs
|
rtbot_sql/runtime.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""User-facing Python runtime API for rtbot-sql."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
from .catalog import InMemoryCatalog, StreamSchema, ViewMeta
|
|
13
|
+
from .compiler import compile_select_to_program, compile_sql, native
|
|
14
|
+
from .formatter import format_rows
|
|
15
|
+
from .jupyter import show_graph as display_graph
|
|
16
|
+
from .pipeline_runner import LocalPipelineRunner
|
|
17
|
+
from .stream_store import InMemoryStreamStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SqlError(RuntimeError):
|
|
21
|
+
def __init__(self, errors: Sequence[Any]):
|
|
22
|
+
self.errors = list(errors)
|
|
23
|
+
messages = [getattr(err, "message", str(err)) for err in self.errors]
|
|
24
|
+
super().__init__("; ".join(messages) if messages else "SQL error")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class InsertResult:
|
|
29
|
+
rows: int
|
|
30
|
+
elapsed_s: float
|
|
31
|
+
rows_per_sec: float
|
|
32
|
+
|
|
33
|
+
def __repr__(self):
|
|
34
|
+
return f"Inserted {self.rows:,} rows in {self.elapsed_s:.2f}s ({self.rows_per_sec:,.0f} rows/sec)"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _enum_name(value: Any) -> str:
|
|
38
|
+
if hasattr(value, "name"):
|
|
39
|
+
return str(value.name)
|
|
40
|
+
text = str(value)
|
|
41
|
+
if "." in text:
|
|
42
|
+
return text.split(".")[-1]
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_limit(sql: str) -> Optional[int]:
|
|
47
|
+
match = re.search(r"\bLIMIT\s+(\d+)\b", sql, flags=re.IGNORECASE)
|
|
48
|
+
if not match:
|
|
49
|
+
return None
|
|
50
|
+
return int(match.group(1))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RtBotSql:
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self._catalog = InMemoryCatalog()
|
|
56
|
+
self._store = InMemoryStreamStore()
|
|
57
|
+
self._runner = LocalPipelineRunner()
|
|
58
|
+
|
|
59
|
+
self._view_pipelines: Dict[str, str] = {}
|
|
60
|
+
self._view_port_maps: Dict[str, Dict[str, str]] = {}
|
|
61
|
+
self._dependencies: Dict[str, set] = defaultdict(set)
|
|
62
|
+
|
|
63
|
+
self._last_timestamp = int(time.time() * 1000)
|
|
64
|
+
self._time_column_name = "time"
|
|
65
|
+
self._time_unit_override: Optional[str] = None
|
|
66
|
+
self._time_formatter: Optional[Callable[[List[int]], List[Any]]] = None
|
|
67
|
+
|
|
68
|
+
def _next_timestamp(self) -> int:
|
|
69
|
+
now = int(time.time() * 1000)
|
|
70
|
+
if now <= self._last_timestamp:
|
|
71
|
+
self._last_timestamp += 1
|
|
72
|
+
else:
|
|
73
|
+
self._last_timestamp = now
|
|
74
|
+
return self._last_timestamp
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _normalize_sql(sql: str) -> str:
|
|
78
|
+
out = sql.strip()
|
|
79
|
+
out = re.sub(r"^\s*CREATE\s+STREAM\b", "CREATE TABLE", out, flags=re.IGNORECASE)
|
|
80
|
+
out = re.sub(r"^\s*DROP\s+STREAM\b", "DROP TABLE", out, flags=re.IGNORECASE)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
def execute(self, sql: str):
|
|
84
|
+
normalized = self._normalize_sql(sql)
|
|
85
|
+
|
|
86
|
+
result = compile_sql(normalized, self._catalog.snapshot())
|
|
87
|
+
if result.has_errors():
|
|
88
|
+
raise SqlError(result.errors)
|
|
89
|
+
|
|
90
|
+
statement = result.statement_type
|
|
91
|
+
if statement == native.StatementType.CREATE_STREAM:
|
|
92
|
+
self._handle_create_stream(result)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
if statement == native.StatementType.INSERT:
|
|
96
|
+
self._handle_insert(result)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
if statement in (
|
|
100
|
+
native.StatementType.CREATE_VIEW,
|
|
101
|
+
native.StatementType.CREATE_MATERIALIZED_VIEW,
|
|
102
|
+
):
|
|
103
|
+
self._handle_create_view(result, statement)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if statement == native.StatementType.DROP:
|
|
107
|
+
self._handle_drop(result)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
if statement == native.StatementType.SELECT:
|
|
111
|
+
return self._handle_select(normalized, result)
|
|
112
|
+
|
|
113
|
+
raise SqlError([f"Unsupported statement type: {statement}"])
|
|
114
|
+
|
|
115
|
+
def _handle_create_stream(self, result: native.CompilationResult) -> None:
|
|
116
|
+
self._catalog.register_stream(result.entity_name, result.stream_schema)
|
|
117
|
+
|
|
118
|
+
def _handle_insert(self, result: native.CompilationResult) -> None:
|
|
119
|
+
stream_name = result.entity_name
|
|
120
|
+
schema = self._catalog.lookup_stream(stream_name)
|
|
121
|
+
if schema is None:
|
|
122
|
+
raise SqlError([f"Unknown stream: {stream_name}"])
|
|
123
|
+
|
|
124
|
+
payload = [float(v) for v in result.insert_payload]
|
|
125
|
+
if schema.columns and len(payload) != len(schema.columns):
|
|
126
|
+
raise SqlError([
|
|
127
|
+
f"INSERT payload length mismatch for {stream_name}: expected {len(schema.columns)}, got {len(payload)}"
|
|
128
|
+
])
|
|
129
|
+
|
|
130
|
+
self._append_and_propagate(stream_name, self._next_timestamp(), payload)
|
|
131
|
+
|
|
132
|
+
def _handle_create_view(
|
|
133
|
+
self,
|
|
134
|
+
result: native.CompilationResult,
|
|
135
|
+
statement: Any,
|
|
136
|
+
) -> None:
|
|
137
|
+
name = result.entity_name
|
|
138
|
+
materialized = statement == native.StatementType.CREATE_MATERIALIZED_VIEW
|
|
139
|
+
|
|
140
|
+
view_meta = ViewMeta(
|
|
141
|
+
name=name,
|
|
142
|
+
entity_type=(
|
|
143
|
+
native.EntityType.MATERIALIZED_VIEW
|
|
144
|
+
if materialized
|
|
145
|
+
else native.EntityType.VIEW
|
|
146
|
+
),
|
|
147
|
+
view_type=result.view_type,
|
|
148
|
+
field_map=dict(result.field_map),
|
|
149
|
+
source_streams=list(result.source_streams),
|
|
150
|
+
program_json=result.program_json,
|
|
151
|
+
output_stream=name,
|
|
152
|
+
per_key_prefix=f"{name}:key:",
|
|
153
|
+
known_keys=[],
|
|
154
|
+
key_index=int(result.key_index),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self._catalog.register_view(name, view_meta)
|
|
158
|
+
for source in view_meta.source_streams:
|
|
159
|
+
self._dependencies[source].add(name)
|
|
160
|
+
|
|
161
|
+
pipeline_id = self._runner.deploy(result.program_json, view_meta.source_streams, {"output_stream": name})
|
|
162
|
+
self._view_pipelines[name] = pipeline_id
|
|
163
|
+
source_port_map: Dict[str, str] = {}
|
|
164
|
+
for i, source in enumerate(view_meta.source_streams):
|
|
165
|
+
source_port_map[source] = f"i{i + 1}"
|
|
166
|
+
self._view_port_maps[name] = source_port_map
|
|
167
|
+
|
|
168
|
+
# Backfill the new view from already-known source data.
|
|
169
|
+
if self._uses_snapshot_sync(name):
|
|
170
|
+
source_rank = {source: i for i, source in enumerate(view_meta.source_streams)}
|
|
171
|
+
events = []
|
|
172
|
+
for source in view_meta.source_streams:
|
|
173
|
+
for msg in self._store.read(source):
|
|
174
|
+
events.append((msg.timestamp, source_rank[source], source, msg.values))
|
|
175
|
+
events.sort(key=lambda item: (item[0], item[1]))
|
|
176
|
+
|
|
177
|
+
history: Dict[str, List[Any]] = {source: [] for source in view_meta.source_streams}
|
|
178
|
+
for timestamp, _, trigger_source, trigger_values in events:
|
|
179
|
+
history[trigger_source].append((timestamp, list(trigger_values)))
|
|
180
|
+
|
|
181
|
+
snapshot: Dict[str, List[float]] = {}
|
|
182
|
+
ready = True
|
|
183
|
+
for source in view_meta.source_streams:
|
|
184
|
+
if source == trigger_source:
|
|
185
|
+
snapshot[source] = list(trigger_values)
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
prior = None
|
|
189
|
+
for prior_timestamp, prior_values in reversed(history[source]):
|
|
190
|
+
if prior_timestamp <= timestamp:
|
|
191
|
+
prior = prior_values
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
if prior is None:
|
|
195
|
+
ready = False
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
snapshot[source] = list(prior)
|
|
199
|
+
|
|
200
|
+
if not ready:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
outputs = []
|
|
204
|
+
for source in view_meta.source_streams:
|
|
205
|
+
port = source_port_map.get(source, "i1")
|
|
206
|
+
outputs.extend(self._runner.feed(pipeline_id, timestamp, snapshot[source], port=port))
|
|
207
|
+
|
|
208
|
+
for out in outputs:
|
|
209
|
+
self._append_and_propagate(name, out.timestamp, out.values)
|
|
210
|
+
else:
|
|
211
|
+
for i, source in enumerate(view_meta.source_streams):
|
|
212
|
+
port = f"i{i + 1}"
|
|
213
|
+
for msg in self._store.read(source):
|
|
214
|
+
outputs = self._runner.feed(pipeline_id, msg.timestamp, msg.values, port=port)
|
|
215
|
+
for out in outputs:
|
|
216
|
+
self._append_and_propagate(name, out.timestamp, out.values)
|
|
217
|
+
|
|
218
|
+
def _handle_drop(self, result: native.CompilationResult) -> None:
|
|
219
|
+
name = result.drop_entity_name
|
|
220
|
+
|
|
221
|
+
if name in self._view_pipelines:
|
|
222
|
+
self._runner.destroy(self._view_pipelines[name])
|
|
223
|
+
self._view_pipelines.pop(name, None)
|
|
224
|
+
self._view_port_maps.pop(name, None)
|
|
225
|
+
|
|
226
|
+
self._catalog.drop(name)
|
|
227
|
+
self._store.clear(name)
|
|
228
|
+
|
|
229
|
+
for dependents in self._dependencies.values():
|
|
230
|
+
dependents.discard(name)
|
|
231
|
+
|
|
232
|
+
self._dependencies.pop(name, None)
|
|
233
|
+
|
|
234
|
+
def _uses_snapshot_sync(self, dependent: str) -> bool:
|
|
235
|
+
view = self._catalog.lookup_view(dependent)
|
|
236
|
+
if view is None or len(view.source_streams) <= 1:
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
for source in view.source_streams:
|
|
240
|
+
if self._catalog.lookup_stream(source) is None:
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
def _feed_dependent_pipeline(
|
|
246
|
+
self,
|
|
247
|
+
dependent: str,
|
|
248
|
+
stream_name: str,
|
|
249
|
+
timestamp: int,
|
|
250
|
+
values: List[float],
|
|
251
|
+
) -> List[Any]:
|
|
252
|
+
pipeline_id = self._view_pipelines.get(dependent)
|
|
253
|
+
if pipeline_id is None:
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
port_map = self._view_port_maps.get(dependent, {})
|
|
257
|
+
|
|
258
|
+
if not self._uses_snapshot_sync(dependent):
|
|
259
|
+
port = port_map.get(stream_name, "i1")
|
|
260
|
+
return self._runner.feed(pipeline_id, timestamp, values, port=port)
|
|
261
|
+
|
|
262
|
+
view = self._catalog.lookup_view(dependent)
|
|
263
|
+
if view is None:
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
snapshot: Dict[str, List[float]] = {}
|
|
267
|
+
for source in view.source_streams:
|
|
268
|
+
if source == stream_name:
|
|
269
|
+
snapshot[source] = list(values)
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
prior = self._store.read_latest_before(source, timestamp)
|
|
273
|
+
if prior is None:
|
|
274
|
+
return []
|
|
275
|
+
snapshot[source] = list(prior.values)
|
|
276
|
+
|
|
277
|
+
outputs = []
|
|
278
|
+
for source in view.source_streams:
|
|
279
|
+
port = port_map.get(source, "i1")
|
|
280
|
+
outputs.extend(self._runner.feed(pipeline_id, timestamp, snapshot[source], port=port))
|
|
281
|
+
return outputs
|
|
282
|
+
|
|
283
|
+
def _append_and_propagate(self, stream_name: str, timestamp: int, values: List[float]) -> None:
|
|
284
|
+
self._store.append(stream_name, timestamp, values)
|
|
285
|
+
|
|
286
|
+
view = self._catalog.lookup_view(stream_name)
|
|
287
|
+
if (
|
|
288
|
+
view is not None
|
|
289
|
+
and view.view_type == native.ViewType.KEYED
|
|
290
|
+
and 0 <= view.key_index < len(values)
|
|
291
|
+
):
|
|
292
|
+
self._catalog.add_key(stream_name, float(values[view.key_index]))
|
|
293
|
+
|
|
294
|
+
for dependent in list(self._dependencies.get(stream_name, set())):
|
|
295
|
+
outputs = self._feed_dependent_pipeline(dependent, stream_name, timestamp, values)
|
|
296
|
+
for out in outputs:
|
|
297
|
+
self._append_and_propagate(dependent, out.timestamp, out.values)
|
|
298
|
+
|
|
299
|
+
def _project_messages(
|
|
300
|
+
self,
|
|
301
|
+
messages: Sequence[Any],
|
|
302
|
+
field_map: Dict[str, int],
|
|
303
|
+
) -> List[List[float]]:
|
|
304
|
+
ordered = sorted(field_map.items(), key=lambda item: item[1])
|
|
305
|
+
indices = [index for _, index in ordered]
|
|
306
|
+
|
|
307
|
+
rows: List[List[float]] = []
|
|
308
|
+
for msg in messages:
|
|
309
|
+
values = list(msg.values)
|
|
310
|
+
rows.append([
|
|
311
|
+
float(values[idx]) if 0 <= idx < len(values) else 0.0
|
|
312
|
+
for idx in indices
|
|
313
|
+
])
|
|
314
|
+
return rows
|
|
315
|
+
|
|
316
|
+
def _resolve_field_map(self, source: str, field_map: Dict[str, int]) -> Dict[str, int]:
|
|
317
|
+
if field_map:
|
|
318
|
+
return dict(field_map)
|
|
319
|
+
|
|
320
|
+
source_view = self._catalog.lookup_view(source)
|
|
321
|
+
if source_view is not None:
|
|
322
|
+
return dict(source_view.field_map)
|
|
323
|
+
|
|
324
|
+
source_stream = self._catalog.lookup_stream(source)
|
|
325
|
+
if source_stream is not None:
|
|
326
|
+
return {column.name: column.index for column in source_stream.columns}
|
|
327
|
+
|
|
328
|
+
source_table = self._catalog.lookup_table(source)
|
|
329
|
+
if source_table is not None:
|
|
330
|
+
return {column.name: column.index for column in source_table.columns}
|
|
331
|
+
|
|
332
|
+
return {}
|
|
333
|
+
|
|
334
|
+
def _format_time_values(self, timestamps: List[int]) -> List[Any]:
|
|
335
|
+
if self._time_formatter is not None:
|
|
336
|
+
return list(self._time_formatter(list(timestamps)))
|
|
337
|
+
|
|
338
|
+
if not timestamps:
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
import pandas as pd # type: ignore
|
|
343
|
+
except Exception:
|
|
344
|
+
return [int(ts) for ts in timestamps]
|
|
345
|
+
|
|
346
|
+
unit = self._time_unit_override
|
|
347
|
+
if unit is None:
|
|
348
|
+
max_abs = max(abs(int(ts)) for ts in timestamps)
|
|
349
|
+
if max_abs >= 10**17:
|
|
350
|
+
unit = "ns"
|
|
351
|
+
elif max_abs >= 10**14:
|
|
352
|
+
unit = "us"
|
|
353
|
+
elif max_abs >= 10**11:
|
|
354
|
+
unit = "ms"
|
|
355
|
+
else:
|
|
356
|
+
unit = "s"
|
|
357
|
+
|
|
358
|
+
return list(pd.to_datetime(timestamps, unit=unit))
|
|
359
|
+
|
|
360
|
+
def configure_time_format(
|
|
361
|
+
self,
|
|
362
|
+
*,
|
|
363
|
+
unit: Optional[str] = None,
|
|
364
|
+
formatter: Optional[Callable[[List[int]], List[Any]]] = None,
|
|
365
|
+
column_name: str = "time",
|
|
366
|
+
) -> None:
|
|
367
|
+
if unit is not None and unit not in {"s", "ms", "us", "ns"}:
|
|
368
|
+
raise SqlError([f"Invalid time unit '{unit}'. Expected one of: s, ms, us, ns"])
|
|
369
|
+
self._time_unit_override = unit
|
|
370
|
+
self._time_formatter = formatter
|
|
371
|
+
self._time_column_name = column_name
|
|
372
|
+
|
|
373
|
+
def _execute_tier1(self, sql: str, result: native.CompilationResult):
|
|
374
|
+
source = result.source_streams[0] if result.source_streams else ""
|
|
375
|
+
if not source:
|
|
376
|
+
return format_rows([], {}, timestamps=[])
|
|
377
|
+
|
|
378
|
+
effective_field_map = self._resolve_field_map(source, dict(result.field_map))
|
|
379
|
+
limit = _extract_limit(sql)
|
|
380
|
+
source_view = self._catalog.lookup_view(source)
|
|
381
|
+
|
|
382
|
+
if (
|
|
383
|
+
source_view is not None
|
|
384
|
+
and source_view.view_type == native.ViewType.KEYED
|
|
385
|
+
and " where " not in sql.lower()
|
|
386
|
+
):
|
|
387
|
+
known_keys = sorted(self._catalog.get_known_keys(source))
|
|
388
|
+
latest_by_key = self._store.read_latest_per_key(
|
|
389
|
+
source,
|
|
390
|
+
known_keys,
|
|
391
|
+
source_view.key_index,
|
|
392
|
+
)
|
|
393
|
+
messages = [latest_by_key[k] for k in known_keys if k in latest_by_key]
|
|
394
|
+
if limit is not None:
|
|
395
|
+
messages = messages[:limit]
|
|
396
|
+
else:
|
|
397
|
+
if limit is None:
|
|
398
|
+
messages = self._store.read(source)
|
|
399
|
+
else:
|
|
400
|
+
messages = self._store.read_latest(source, limit)
|
|
401
|
+
|
|
402
|
+
rows = self._project_messages(messages, effective_field_map)
|
|
403
|
+
timestamps = [msg.timestamp for msg in messages]
|
|
404
|
+
return format_rows(
|
|
405
|
+
rows,
|
|
406
|
+
effective_field_map,
|
|
407
|
+
timestamps=timestamps,
|
|
408
|
+
time_values=self._format_time_values(timestamps),
|
|
409
|
+
time_column=self._time_column_name,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def _execute_with_pipeline(self, sql: str, result: native.CompilationResult):
|
|
413
|
+
original_field_map = dict(result.field_map)
|
|
414
|
+
runtime_result = result
|
|
415
|
+
if not runtime_result.program_json:
|
|
416
|
+
runtime_result = compile_select_to_program(sql, self._catalog.snapshot())
|
|
417
|
+
if runtime_result.has_errors():
|
|
418
|
+
raise SqlError(runtime_result.errors)
|
|
419
|
+
|
|
420
|
+
effective_field_map = dict(runtime_result.field_map)
|
|
421
|
+
if not effective_field_map:
|
|
422
|
+
effective_field_map = original_field_map
|
|
423
|
+
if not effective_field_map and runtime_result.source_streams:
|
|
424
|
+
effective_field_map = self._resolve_field_map(runtime_result.source_streams[0], {})
|
|
425
|
+
|
|
426
|
+
if not runtime_result.source_streams:
|
|
427
|
+
return format_rows(
|
|
428
|
+
[],
|
|
429
|
+
effective_field_map,
|
|
430
|
+
timestamps=[],
|
|
431
|
+
time_values=[],
|
|
432
|
+
time_column=self._time_column_name,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
inputs: List[Tuple[int, List[float], str]] = []
|
|
436
|
+
uses_snapshot_sync = (
|
|
437
|
+
len(runtime_result.source_streams) > 1
|
|
438
|
+
and all(self._catalog.lookup_stream(source) is not None for source in runtime_result.source_streams)
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if uses_snapshot_sync:
|
|
442
|
+
source_rank = {source: i for i, source in enumerate(runtime_result.source_streams)}
|
|
443
|
+
events: List[Tuple[int, int, str, List[float]]] = []
|
|
444
|
+
for source in runtime_result.source_streams:
|
|
445
|
+
for msg in self._store.read(source):
|
|
446
|
+
events.append((msg.timestamp, source_rank[source], source, list(msg.values)))
|
|
447
|
+
events.sort(key=lambda item: (item[0], item[1]))
|
|
448
|
+
|
|
449
|
+
latest_by_source: Dict[str, List[float]] = {}
|
|
450
|
+
for timestamp, _, trigger_source, trigger_values in events:
|
|
451
|
+
latest_by_source[trigger_source] = list(trigger_values)
|
|
452
|
+
if len(latest_by_source) < len(runtime_result.source_streams):
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
for i, source in enumerate(runtime_result.source_streams):
|
|
456
|
+
inputs.append((timestamp, list(latest_by_source[source]), f"i{i + 1}"))
|
|
457
|
+
else:
|
|
458
|
+
for i, source in enumerate(runtime_result.source_streams):
|
|
459
|
+
port = f"i{i + 1}"
|
|
460
|
+
inputs.extend((msg.timestamp, msg.values, port) for msg in self._store.read(source))
|
|
461
|
+
|
|
462
|
+
outputs = self._runner.run_once(runtime_result.program_json, inputs)
|
|
463
|
+
timestamps = [out.timestamp for out in outputs]
|
|
464
|
+
rows = [list(out.values) for out in outputs]
|
|
465
|
+
|
|
466
|
+
limit = _extract_limit(sql)
|
|
467
|
+
if limit is not None:
|
|
468
|
+
rows = rows[:limit]
|
|
469
|
+
timestamps = timestamps[:limit]
|
|
470
|
+
|
|
471
|
+
return format_rows(
|
|
472
|
+
rows,
|
|
473
|
+
effective_field_map,
|
|
474
|
+
timestamps=timestamps,
|
|
475
|
+
time_values=self._format_time_values(timestamps),
|
|
476
|
+
time_column=self._time_column_name,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
def _handle_select(self, sql: str, result: native.CompilationResult):
|
|
480
|
+
if result.select_tier == native.SelectTier.TIER1_READ:
|
|
481
|
+
return self._execute_tier1(sql, result)
|
|
482
|
+
return self._execute_with_pipeline(sql, result)
|
|
483
|
+
|
|
484
|
+
def explain(self, sql: str) -> Dict[str, Any]:
|
|
485
|
+
normalized = self._normalize_sql(sql)
|
|
486
|
+
result = compile_sql(normalized, self._catalog.snapshot())
|
|
487
|
+
|
|
488
|
+
output = {
|
|
489
|
+
"statement_type": _enum_name(result.statement_type),
|
|
490
|
+
"select_tier": _enum_name(result.select_tier),
|
|
491
|
+
"entity_name": result.entity_name,
|
|
492
|
+
"source_streams": list(result.source_streams),
|
|
493
|
+
"field_map": dict(result.field_map),
|
|
494
|
+
"view_type": _enum_name(result.view_type),
|
|
495
|
+
"key_index": int(result.key_index),
|
|
496
|
+
"program_json": None,
|
|
497
|
+
"errors": [
|
|
498
|
+
{
|
|
499
|
+
"message": err.message,
|
|
500
|
+
"line": int(err.line),
|
|
501
|
+
"column": int(err.column),
|
|
502
|
+
}
|
|
503
|
+
for err in result.errors
|
|
504
|
+
],
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if result.program_json:
|
|
508
|
+
output["program_json"] = json.loads(result.program_json)
|
|
509
|
+
|
|
510
|
+
return output
|
|
511
|
+
|
|
512
|
+
def show_graph(self, view_name: str):
|
|
513
|
+
view = self._catalog.lookup_view(view_name)
|
|
514
|
+
if view is None:
|
|
515
|
+
raise SqlError([f"Unknown view: {view_name}"])
|
|
516
|
+
return display_graph(view.program_json)
|
|
517
|
+
|
|
518
|
+
def debug(self, sql: str) -> Dict[str, Any]:
|
|
519
|
+
return self.explain(sql)
|
|
520
|
+
|
|
521
|
+
def export_for_redis(self, view_name: str) -> str:
|
|
522
|
+
view = self._catalog.lookup_view(view_name)
|
|
523
|
+
if view is None:
|
|
524
|
+
raise SqlError([f"Unknown view: {view_name}"])
|
|
525
|
+
|
|
526
|
+
kind = (
|
|
527
|
+
"MATERIALIZED VIEW"
|
|
528
|
+
if view.entity_type == native.EntityType.MATERIALIZED_VIEW
|
|
529
|
+
else "VIEW"
|
|
530
|
+
)
|
|
531
|
+
sources = ", ".join(view.source_streams)
|
|
532
|
+
return (
|
|
533
|
+
"-- Original SQL text is not persisted in the Python runtime catalog.\n"
|
|
534
|
+
f"CREATE {kind} {view_name} AS /* sources: {sources} */;"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def insert_dataframe(
|
|
538
|
+
self,
|
|
539
|
+
stream_name: str,
|
|
540
|
+
dataframe: Any,
|
|
541
|
+
column_map: Optional[Dict[str, str]] = None,
|
|
542
|
+
) -> InsertResult:
|
|
543
|
+
schema = self._catalog.lookup_stream(stream_name)
|
|
544
|
+
if schema is None:
|
|
545
|
+
raise SqlError([f"Unknown stream: {stream_name}"])
|
|
546
|
+
|
|
547
|
+
cmap = column_map or {}
|
|
548
|
+
|
|
549
|
+
time_col = None
|
|
550
|
+
if hasattr(dataframe, "columns"):
|
|
551
|
+
for candidate in ("time", "timestamp", "ts"):
|
|
552
|
+
if candidate in dataframe.columns:
|
|
553
|
+
time_col = candidate
|
|
554
|
+
break
|
|
555
|
+
|
|
556
|
+
row_count = 0
|
|
557
|
+
t0 = time.perf_counter()
|
|
558
|
+
|
|
559
|
+
if hasattr(dataframe, "iterrows"):
|
|
560
|
+
for _, row in dataframe.iterrows():
|
|
561
|
+
timestamp = int(row[time_col]) if time_col else self._next_timestamp()
|
|
562
|
+
values = [float(row[cmap.get(col.name, col.name)]) for col in schema.columns]
|
|
563
|
+
self._append_and_propagate(stream_name, timestamp, values)
|
|
564
|
+
row_count += 1
|
|
565
|
+
else:
|
|
566
|
+
for row in dataframe:
|
|
567
|
+
if isinstance(row, dict):
|
|
568
|
+
if time_col:
|
|
569
|
+
timestamp = int(row.get(time_col, self._next_timestamp()))
|
|
570
|
+
else:
|
|
571
|
+
dict_time_col = None
|
|
572
|
+
for candidate in ("time", "timestamp", "ts"):
|
|
573
|
+
if candidate in row:
|
|
574
|
+
dict_time_col = candidate
|
|
575
|
+
break
|
|
576
|
+
timestamp = int(row[dict_time_col]) if dict_time_col else self._next_timestamp()
|
|
577
|
+
values = [float(row[cmap.get(col.name, col.name)]) for col in schema.columns]
|
|
578
|
+
else:
|
|
579
|
+
timestamp = self._next_timestamp()
|
|
580
|
+
values = [float(v) for v in row]
|
|
581
|
+
self._append_and_propagate(stream_name, timestamp, values)
|
|
582
|
+
row_count += 1
|
|
583
|
+
|
|
584
|
+
elapsed = time.perf_counter() - t0
|
|
585
|
+
rps = row_count / elapsed if elapsed > 0 else float('inf')
|
|
586
|
+
return InsertResult(rows=row_count, elapsed_s=elapsed, rows_per_sec=rps)
|
|
587
|
+
|
|
588
|
+
def get_catalog(self) -> InMemoryCatalog:
|
|
589
|
+
return self._catalog
|
|
590
|
+
|
|
591
|
+
def get_store(self) -> InMemoryStreamStore:
|
|
592
|
+
return self._store
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""In-memory stream message store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Message:
|
|
11
|
+
timestamp: int
|
|
12
|
+
values: List[float]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _coerce_message(item: Any) -> Message:
|
|
16
|
+
if isinstance(item, Message):
|
|
17
|
+
return Message(int(item.timestamp), [float(v) for v in item.values])
|
|
18
|
+
if isinstance(item, dict):
|
|
19
|
+
return Message(int(item["timestamp"]), [float(v) for v in item["values"]])
|
|
20
|
+
if isinstance(item, (tuple, list)) and len(item) == 2:
|
|
21
|
+
return Message(int(item[0]), [float(v) for v in item[1]])
|
|
22
|
+
raise TypeError(f"Unsupported message payload: {type(item)!r}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InMemoryStreamStore:
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._streams: Dict[str, List[Message]] = {}
|
|
28
|
+
|
|
29
|
+
def append(self, stream_name: str, timestamp: int, vector: List[float]) -> None:
|
|
30
|
+
messages = self._streams.setdefault(stream_name, [])
|
|
31
|
+
messages.append(Message(int(timestamp), [float(v) for v in vector]))
|
|
32
|
+
|
|
33
|
+
def append_batch(self, stream_name: str, messages: Iterable[Any]) -> None:
|
|
34
|
+
for item in messages:
|
|
35
|
+
msg = _coerce_message(item)
|
|
36
|
+
self.append(stream_name, msg.timestamp, msg.values)
|
|
37
|
+
|
|
38
|
+
def read(self, stream_name: str) -> List[Message]:
|
|
39
|
+
return list(self._streams.get(stream_name, []))
|
|
40
|
+
|
|
41
|
+
def read_latest(self, stream_name: str, count: int) -> List[Message]:
|
|
42
|
+
if count <= 0:
|
|
43
|
+
return []
|
|
44
|
+
data = self._streams.get(stream_name, [])
|
|
45
|
+
if count >= len(data):
|
|
46
|
+
return list(data)
|
|
47
|
+
return list(data[-count:])
|
|
48
|
+
|
|
49
|
+
def read_latest_before(self, stream_name: str, timestamp: int) -> Optional[Message]:
|
|
50
|
+
data = self._streams.get(stream_name, [])
|
|
51
|
+
target = int(timestamp)
|
|
52
|
+
for msg in reversed(data):
|
|
53
|
+
if msg.timestamp <= target:
|
|
54
|
+
return Message(msg.timestamp, list(msg.values))
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def read_range(
|
|
58
|
+
self,
|
|
59
|
+
stream_name: str,
|
|
60
|
+
from_time: Optional[int],
|
|
61
|
+
to_time: Optional[int],
|
|
62
|
+
limit: Optional[int] = None,
|
|
63
|
+
) -> List[Message]:
|
|
64
|
+
result: List[Message] = []
|
|
65
|
+
for msg in self._streams.get(stream_name, []):
|
|
66
|
+
if from_time is not None and msg.timestamp < from_time:
|
|
67
|
+
continue
|
|
68
|
+
if to_time is not None and msg.timestamp > to_time:
|
|
69
|
+
continue
|
|
70
|
+
result.append(msg)
|
|
71
|
+
if limit is not None and limit > 0 and len(result) >= limit:
|
|
72
|
+
break
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
def read_latest_per_key(
|
|
76
|
+
self,
|
|
77
|
+
view_name: str,
|
|
78
|
+
known_keys: Iterable[float],
|
|
79
|
+
key_index: int,
|
|
80
|
+
) -> Dict[float, Message]:
|
|
81
|
+
wanted = {float(v) for v in known_keys}
|
|
82
|
+
out: Dict[float, Message] = {}
|
|
83
|
+
|
|
84
|
+
if not wanted:
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
for msg in reversed(self._streams.get(view_name, [])):
|
|
88
|
+
if key_index < 0 or key_index >= len(msg.values):
|
|
89
|
+
continue
|
|
90
|
+
key = float(msg.values[key_index])
|
|
91
|
+
if key not in wanted or key in out:
|
|
92
|
+
continue
|
|
93
|
+
out[key] = msg
|
|
94
|
+
if len(out) == len(wanted):
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
def clear(self, stream_name: str) -> None:
|
|
100
|
+
self._streams.pop(stream_name, None)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: rtbot-sql
|
|
3
|
+
Author: Katenaria
|
|
4
|
+
Author-email: services@katenaria.com
|
|
5
|
+
Home-page: https://rtbot.dev
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Summary: Real-time SQL computation engine for streaming numerical data
|
|
9
|
+
Project-URL: Documentation, https://rtbot.dev/docs
|
|
10
|
+
Project-URL: Source, https://github.com/rtbot-dev/rtbot-sql
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: Other/Proprietary License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Requires-Python: >=3.13
|
|
21
|
+
Version: 0.1.14
|
|
22
|
+
|
|
23
|
+
# RtBot SQL
|
|
24
|
+
|
|
25
|
+
Real-time SQL computation engine for streaming numerical data. Write SQL to define streaming pipelines — RtBot compiles them into high-performance C++ programs that process data incrementally, one message at a time.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from rtbot_sql import RtBotSql
|
|
31
|
+
|
|
32
|
+
sql = RtBotSql()
|
|
33
|
+
sql.execute("CREATE STREAM sensors (temperature DOUBLE, pressure DOUBLE)")
|
|
34
|
+
sql.execute("""
|
|
35
|
+
CREATE MATERIALIZED VIEW alerts AS
|
|
36
|
+
SELECT temperature, pressure,
|
|
37
|
+
MOVING_AVERAGE(temperature, 50) AS avg_temp,
|
|
38
|
+
MOVING_STD(temperature, 50) AS std_temp
|
|
39
|
+
FROM sensors
|
|
40
|
+
WHERE ABS(temperature - MOVING_AVERAGE(temperature, 50)) > 2 * MOVING_STD(temperature, 50)
|
|
41
|
+
""")
|
|
42
|
+
|
|
43
|
+
import pandas as pd
|
|
44
|
+
df = pd.read_csv("sensor_history.csv")
|
|
45
|
+
sql.insert_dataframe("sensors", df)
|
|
46
|
+
results = sql.execute("SELECT * FROM alerts")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- **One language, all stages** — the SQL you write in a notebook is the same SQL that runs in production
|
|
52
|
+
- **Incremental execution** — each new data point updates pipeline state in constant time
|
|
53
|
+
- **Deterministic** — same input always produces the same output, regardless of timing
|
|
54
|
+
- **High performance** — native C++ engine with Python bindings via pybind11
|
|
55
|
+
|
|
56
|
+
## Documentation
|
|
57
|
+
|
|
58
|
+
- [Getting Started](https://rtbot.dev/docs/user-guide/getting-started)
|
|
59
|
+
- [Python Quickstart](https://rtbot.dev/docs/user-guide/quickstart-python)
|
|
60
|
+
- [SQL Reference](https://rtbot.dev/docs/reference/rtbot-sql/overview)
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
Proprietary. See [rtbot.dev](https://rtbot.dev) for licensing options.
|
|
65
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
rtbot_sql/_rtbot_sql_native.so,sha256=LwP12rSJOE6uLgjUajKHYfosUawhpo8OlnCAfn0lgo8,6317568
|
|
2
|
+
rtbot_sql/__init__.py,sha256=7A40ISiExSGggL6uMctNPVmH6wtjjBpY2IGKUhOuiLI,1209
|
|
3
|
+
rtbot_sql/catalog.py,sha256=GgPTJD_LIyhrOMWxualw1LOELOIs8yBleo_sfqQjH14,8170
|
|
4
|
+
rtbot_sql/compiler.py,sha256=hCgOtob2uFBqbpCR7ZPnVxmJ1jBlZvfNd9Qe94HskyE,1643
|
|
5
|
+
rtbot_sql/formatter.py,sha256=34T4-4hGVIRUmH1ut7xD7U-6miMT4Kbn54_Qj1H2Rcc,2340
|
|
6
|
+
rtbot_sql/jupyter.py,sha256=KwiqlK62U-A-a9nHUWPYsRTQJkjuzDIjrUC-ldhBHOs,1242
|
|
7
|
+
rtbot_sql/pipeline_runner.py,sha256=TKvQkk0uCE4Qr7CkjVmQUWQaI8o0t0qexyeQU6Pj3lg,3163
|
|
8
|
+
rtbot_sql/runtime.py,sha256=KJCMXLRoZwJgwLHzTwFH0sspHRyN3jSK10m5pd-mipQ,20436
|
|
9
|
+
rtbot_sql/stream_store.py,sha256=0XNgnNSgKKe1uJ7WgY5jL_t18dgdYAl6VsxXQq1wN-4,3136
|
|
10
|
+
rtbot_sql-0.1.14.dist-info/WHEEL,sha256=N9E0YzaAkxKF8USZMMWbkNIXLjcNcbLNitkJXYC8GwI,101
|
|
11
|
+
rtbot_sql-0.1.14.dist-info/METADATA,sha256=uDCfH0eIFZ0WX7mZPMAi3k2kYMK47xcbaWlrFGNE-pU,2374
|
|
12
|
+
rtbot_sql-0.1.14.dist-info/RECORD,,
|