singlestoredb 1.16.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- singlestoredb/__init__.py +75 -0
- singlestoredb/ai/__init__.py +2 -0
- singlestoredb/ai/chat.py +139 -0
- singlestoredb/ai/embeddings.py +128 -0
- singlestoredb/alchemy/__init__.py +90 -0
- singlestoredb/apps/__init__.py +3 -0
- singlestoredb/apps/_cloud_functions.py +90 -0
- singlestoredb/apps/_config.py +72 -0
- singlestoredb/apps/_connection_info.py +18 -0
- singlestoredb/apps/_dashboards.py +47 -0
- singlestoredb/apps/_process.py +32 -0
- singlestoredb/apps/_python_udfs.py +100 -0
- singlestoredb/apps/_stdout_supress.py +30 -0
- singlestoredb/apps/_uvicorn_util.py +36 -0
- singlestoredb/auth.py +245 -0
- singlestoredb/config.py +484 -0
- singlestoredb/connection.py +1487 -0
- singlestoredb/converters.py +950 -0
- singlestoredb/docstring/__init__.py +33 -0
- singlestoredb/docstring/attrdoc.py +126 -0
- singlestoredb/docstring/common.py +230 -0
- singlestoredb/docstring/epydoc.py +267 -0
- singlestoredb/docstring/google.py +412 -0
- singlestoredb/docstring/numpydoc.py +562 -0
- singlestoredb/docstring/parser.py +100 -0
- singlestoredb/docstring/py.typed +1 -0
- singlestoredb/docstring/rest.py +256 -0
- singlestoredb/docstring/tests/__init__.py +1 -0
- singlestoredb/docstring/tests/_pydoctor.py +21 -0
- singlestoredb/docstring/tests/test_epydoc.py +729 -0
- singlestoredb/docstring/tests/test_google.py +1007 -0
- singlestoredb/docstring/tests/test_numpydoc.py +1100 -0
- singlestoredb/docstring/tests/test_parse_from_object.py +109 -0
- singlestoredb/docstring/tests/test_parser.py +248 -0
- singlestoredb/docstring/tests/test_rest.py +547 -0
- singlestoredb/docstring/tests/test_util.py +70 -0
- singlestoredb/docstring/util.py +141 -0
- singlestoredb/exceptions.py +120 -0
- singlestoredb/functions/__init__.py +16 -0
- singlestoredb/functions/decorator.py +201 -0
- singlestoredb/functions/dtypes.py +1793 -0
- singlestoredb/functions/ext/__init__.py +1 -0
- singlestoredb/functions/ext/arrow.py +375 -0
- singlestoredb/functions/ext/asgi.py +2133 -0
- singlestoredb/functions/ext/json.py +420 -0
- singlestoredb/functions/ext/mmap.py +413 -0
- singlestoredb/functions/ext/rowdat_1.py +724 -0
- singlestoredb/functions/ext/timer.py +89 -0
- singlestoredb/functions/ext/utils.py +218 -0
- singlestoredb/functions/signature.py +1578 -0
- singlestoredb/functions/typing/__init__.py +41 -0
- singlestoredb/functions/typing/numpy.py +20 -0
- singlestoredb/functions/typing/pandas.py +2 -0
- singlestoredb/functions/typing/polars.py +2 -0
- singlestoredb/functions/typing/pyarrow.py +2 -0
- singlestoredb/functions/utils.py +421 -0
- singlestoredb/fusion/__init__.py +11 -0
- singlestoredb/fusion/graphql.py +213 -0
- singlestoredb/fusion/handler.py +916 -0
- singlestoredb/fusion/handlers/__init__.py +0 -0
- singlestoredb/fusion/handlers/export.py +525 -0
- singlestoredb/fusion/handlers/files.py +690 -0
- singlestoredb/fusion/handlers/job.py +660 -0
- singlestoredb/fusion/handlers/models.py +250 -0
- singlestoredb/fusion/handlers/stage.py +502 -0
- singlestoredb/fusion/handlers/utils.py +324 -0
- singlestoredb/fusion/handlers/workspace.py +956 -0
- singlestoredb/fusion/registry.py +249 -0
- singlestoredb/fusion/result.py +399 -0
- singlestoredb/http/__init__.py +27 -0
- singlestoredb/http/connection.py +1267 -0
- singlestoredb/magics/__init__.py +34 -0
- singlestoredb/magics/run_personal.py +137 -0
- singlestoredb/magics/run_shared.py +134 -0
- singlestoredb/management/__init__.py +9 -0
- singlestoredb/management/billing_usage.py +148 -0
- singlestoredb/management/cluster.py +462 -0
- singlestoredb/management/export.py +295 -0
- singlestoredb/management/files.py +1102 -0
- singlestoredb/management/inference_api.py +105 -0
- singlestoredb/management/job.py +887 -0
- singlestoredb/management/manager.py +373 -0
- singlestoredb/management/organization.py +226 -0
- singlestoredb/management/region.py +169 -0
- singlestoredb/management/utils.py +423 -0
- singlestoredb/management/workspace.py +1927 -0
- singlestoredb/mysql/__init__.py +177 -0
- singlestoredb/mysql/_auth.py +298 -0
- singlestoredb/mysql/charset.py +214 -0
- singlestoredb/mysql/connection.py +2032 -0
- singlestoredb/mysql/constants/CLIENT.py +38 -0
- singlestoredb/mysql/constants/COMMAND.py +32 -0
- singlestoredb/mysql/constants/CR.py +78 -0
- singlestoredb/mysql/constants/ER.py +474 -0
- singlestoredb/mysql/constants/EXTENDED_TYPE.py +3 -0
- singlestoredb/mysql/constants/FIELD_TYPE.py +48 -0
- singlestoredb/mysql/constants/FLAG.py +15 -0
- singlestoredb/mysql/constants/SERVER_STATUS.py +10 -0
- singlestoredb/mysql/constants/VECTOR_TYPE.py +6 -0
- singlestoredb/mysql/constants/__init__.py +0 -0
- singlestoredb/mysql/converters.py +271 -0
- singlestoredb/mysql/cursors.py +896 -0
- singlestoredb/mysql/err.py +92 -0
- singlestoredb/mysql/optionfile.py +20 -0
- singlestoredb/mysql/protocol.py +450 -0
- singlestoredb/mysql/tests/__init__.py +19 -0
- singlestoredb/mysql/tests/base.py +126 -0
- singlestoredb/mysql/tests/conftest.py +37 -0
- singlestoredb/mysql/tests/test_DictCursor.py +132 -0
- singlestoredb/mysql/tests/test_SSCursor.py +141 -0
- singlestoredb/mysql/tests/test_basic.py +452 -0
- singlestoredb/mysql/tests/test_connection.py +851 -0
- singlestoredb/mysql/tests/test_converters.py +58 -0
- singlestoredb/mysql/tests/test_cursor.py +141 -0
- singlestoredb/mysql/tests/test_err.py +16 -0
- singlestoredb/mysql/tests/test_issues.py +514 -0
- singlestoredb/mysql/tests/test_load_local.py +75 -0
- singlestoredb/mysql/tests/test_nextset.py +88 -0
- singlestoredb/mysql/tests/test_optionfile.py +27 -0
- singlestoredb/mysql/tests/thirdparty/__init__.py +6 -0
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/__init__.py +9 -0
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/capabilities.py +323 -0
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/dbapi20.py +865 -0
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py +110 -0
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py +224 -0
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py +101 -0
- singlestoredb/mysql/times.py +23 -0
- singlestoredb/notebook/__init__.py +16 -0
- singlestoredb/notebook/_objects.py +213 -0
- singlestoredb/notebook/_portal.py +352 -0
- singlestoredb/py.typed +0 -0
- singlestoredb/pytest.py +352 -0
- singlestoredb/server/__init__.py +0 -0
- singlestoredb/server/docker.py +452 -0
- singlestoredb/server/free_tier.py +267 -0
- singlestoredb/tests/__init__.py +0 -0
- singlestoredb/tests/alltypes.sql +307 -0
- singlestoredb/tests/alltypes_no_nulls.sql +208 -0
- singlestoredb/tests/empty.sql +0 -0
- singlestoredb/tests/ext_funcs/__init__.py +702 -0
- singlestoredb/tests/local_infile.csv +3 -0
- singlestoredb/tests/test.ipynb +18 -0
- singlestoredb/tests/test.sql +680 -0
- singlestoredb/tests/test2.ipynb +18 -0
- singlestoredb/tests/test2.sql +1 -0
- singlestoredb/tests/test_basics.py +1332 -0
- singlestoredb/tests/test_config.py +318 -0
- singlestoredb/tests/test_connection.py +3103 -0
- singlestoredb/tests/test_dbapi.py +27 -0
- singlestoredb/tests/test_exceptions.py +45 -0
- singlestoredb/tests/test_ext_func.py +1472 -0
- singlestoredb/tests/test_ext_func_data.py +1101 -0
- singlestoredb/tests/test_fusion.py +1527 -0
- singlestoredb/tests/test_http.py +288 -0
- singlestoredb/tests/test_management.py +1599 -0
- singlestoredb/tests/test_plugin.py +33 -0
- singlestoredb/tests/test_results.py +171 -0
- singlestoredb/tests/test_types.py +132 -0
- singlestoredb/tests/test_udf.py +737 -0
- singlestoredb/tests/test_udf_returns.py +459 -0
- singlestoredb/tests/test_vectorstore.py +51 -0
- singlestoredb/tests/test_xdict.py +333 -0
- singlestoredb/tests/utils.py +141 -0
- singlestoredb/types.py +373 -0
- singlestoredb/utils/__init__.py +0 -0
- singlestoredb/utils/config.py +950 -0
- singlestoredb/utils/convert_rows.py +69 -0
- singlestoredb/utils/debug.py +13 -0
- singlestoredb/utils/dtypes.py +205 -0
- singlestoredb/utils/events.py +65 -0
- singlestoredb/utils/mogrify.py +151 -0
- singlestoredb/utils/results.py +585 -0
- singlestoredb/utils/xdict.py +425 -0
- singlestoredb/vectorstore.py +192 -0
- singlestoredb/warnings.py +5 -0
- singlestoredb-1.16.1.dist-info/METADATA +165 -0
- singlestoredb-1.16.1.dist-info/RECORD +183 -0
- singlestoredb-1.16.1.dist-info/WHEEL +5 -0
- singlestoredb-1.16.1.dist-info/entry_points.txt +2 -0
- singlestoredb-1.16.1.dist-info/licenses/LICENSE +201 -0
- singlestoredb-1.16.1.dist-info/top_level.txt +3 -0
- sqlx/__init__.py +4 -0
- sqlx/magic.py +113 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import Callable
|
|
9
|
+
from typing import Dict
|
|
10
|
+
from typing import List
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from typing import Tuple
|
|
13
|
+
from typing import Union
|
|
14
|
+
|
|
15
|
+
from . import _objects as obj
|
|
16
|
+
from ..management import workspace as mgr
|
|
17
|
+
from ..utils import events
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from IPython import display
|
|
21
|
+
has_ipython = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
has_ipython = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Portal(object):
|
|
27
|
+
"""SingleStore Portal information."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._connection_info: Dict[str, Any] = {}
|
|
31
|
+
self._authentication_info: Dict[str, Any] = {}
|
|
32
|
+
self._theme_info: Dict[str, Any] = {}
|
|
33
|
+
events.subscribe(self._request)
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
attrs = []
|
|
37
|
+
for name in [
|
|
38
|
+
'organization_id', 'workspace_group_id', 'workspace_id',
|
|
39
|
+
'host', 'port', 'user', 'password', 'default_database',
|
|
40
|
+
]:
|
|
41
|
+
if name == 'password':
|
|
42
|
+
if self.password is not None:
|
|
43
|
+
attrs.append("password='***'")
|
|
44
|
+
else:
|
|
45
|
+
attrs.append('password=None')
|
|
46
|
+
else:
|
|
47
|
+
attrs.append(f'{name}={getattr(self, name)!r}')
|
|
48
|
+
return f'{type(self).__name__}({", ".join(attrs)})'
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return str(self)
|
|
52
|
+
|
|
53
|
+
def _call_javascript(
|
|
54
|
+
self,
|
|
55
|
+
func: str,
|
|
56
|
+
args: Optional[List[Any]] = None,
|
|
57
|
+
wait_on_condition: Optional[Callable[[], bool]] = None,
|
|
58
|
+
timeout_message: str = 'timed out waiting on condition',
|
|
59
|
+
wait_interval: float = 2.0,
|
|
60
|
+
timeout: float = 60.0,
|
|
61
|
+
) -> None:
|
|
62
|
+
if not has_ipython or not func:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if not re.match(r'^[A-Z_][\w\._]*$', func, flags=re.I):
|
|
66
|
+
raise ValueError(f'function name is not valid: {func}')
|
|
67
|
+
|
|
68
|
+
args = args if args else []
|
|
69
|
+
|
|
70
|
+
code = f'''
|
|
71
|
+
if (window.singlestore && window.singlestore.portal) {{
|
|
72
|
+
window.singlestore.portal.{func}.apply(
|
|
73
|
+
window,
|
|
74
|
+
JSON.parse({repr(json.dumps(args))})
|
|
75
|
+
)
|
|
76
|
+
}}
|
|
77
|
+
'''
|
|
78
|
+
|
|
79
|
+
display.display(display.Javascript(code))
|
|
80
|
+
|
|
81
|
+
if wait_on_condition is not None:
|
|
82
|
+
elapsed = 0.0
|
|
83
|
+
while True:
|
|
84
|
+
if wait_on_condition():
|
|
85
|
+
break
|
|
86
|
+
if elapsed > timeout:
|
|
87
|
+
raise RuntimeError(timeout_message)
|
|
88
|
+
time.sleep(wait_interval)
|
|
89
|
+
elapsed += wait_interval
|
|
90
|
+
|
|
91
|
+
def _request(self, msg: Dict[str, Any]) -> None:
|
|
92
|
+
"""Handle request on the control stream."""
|
|
93
|
+
func = getattr(self, '_handle_' + msg.get('name', 'unknown').split('.')[-1])
|
|
94
|
+
if func is not None:
|
|
95
|
+
func(msg.get('data', {}))
|
|
96
|
+
|
|
97
|
+
def _handle_connection_updated(self, data: Dict[str, Any]) -> None:
|
|
98
|
+
"""Handle connection_updated event."""
|
|
99
|
+
self._connection_info = dict(data)
|
|
100
|
+
|
|
101
|
+
def _handle_authentication_updated(self, data: Dict[str, Any]) -> None:
|
|
102
|
+
"""Handle authentication_updated event."""
|
|
103
|
+
self._authentication_info = dict(data)
|
|
104
|
+
|
|
105
|
+
def _handle_theme_updated(self, data: Dict[str, Any]) -> None:
|
|
106
|
+
"""Handle theme_updated event."""
|
|
107
|
+
self._theme_info = dict(data)
|
|
108
|
+
|
|
109
|
+
def _handle_unknown(self, data: Dict[str, Any]) -> None:
|
|
110
|
+
"""Handle unknown events."""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def organization_id(self) -> Optional[str]:
|
|
115
|
+
"""Organization ID."""
|
|
116
|
+
try:
|
|
117
|
+
return self._connection_info['organization']
|
|
118
|
+
except KeyError:
|
|
119
|
+
return os.environ.get('SINGLESTOREDB_ORGANIZATION')
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def organization(self) -> obj.Organization:
|
|
123
|
+
"""Organization."""
|
|
124
|
+
return obj.organization
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def stage(self) -> obj.Stage:
|
|
128
|
+
"""Stage."""
|
|
129
|
+
return obj.stage
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def secrets(self) -> obj.Secrets:
|
|
133
|
+
"""Secrets."""
|
|
134
|
+
return obj.secrets
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def workspace_group_id(self) -> Optional[str]:
|
|
138
|
+
"""Workspace Group ID."""
|
|
139
|
+
try:
|
|
140
|
+
return self._connection_info['workspace_group']
|
|
141
|
+
except KeyError:
|
|
142
|
+
return os.environ.get('SINGLESTOREDB_WORKSPACE_GROUP')
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def workspace_group(self) -> obj.WorkspaceGroup:
|
|
146
|
+
"""Workspace group."""
|
|
147
|
+
return obj.workspace_group
|
|
148
|
+
|
|
149
|
+
@workspace_group.setter
|
|
150
|
+
def workspace_group(self) -> None:
|
|
151
|
+
"""Set workspace group."""
|
|
152
|
+
raise AttributeError(
|
|
153
|
+
'workspace group can not be set explictly; ' +
|
|
154
|
+
'you can only set a workspace',
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def workspace_id(self) -> Optional[str]:
|
|
159
|
+
"""Workspace ID."""
|
|
160
|
+
try:
|
|
161
|
+
return self._connection_info['workspace']
|
|
162
|
+
except KeyError:
|
|
163
|
+
return os.environ.get('SINGLESTOREDB_WORKSPACE')
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def workspace(self) -> obj.Workspace:
|
|
167
|
+
"""Workspace."""
|
|
168
|
+
return obj.workspace
|
|
169
|
+
|
|
170
|
+
@workspace.setter
|
|
171
|
+
def workspace(self, workspace_spec: Union[str, Tuple[str, str]]) -> None:
|
|
172
|
+
"""Set workspace."""
|
|
173
|
+
if isinstance(workspace_spec, tuple):
|
|
174
|
+
# 2-element tuple: (workspace_group_id, workspace_name_or_id)
|
|
175
|
+
workspace_group_id, name_or_id = workspace_spec
|
|
176
|
+
uuid_pattern = (
|
|
177
|
+
r'[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}'
|
|
178
|
+
)
|
|
179
|
+
if re.match(uuid_pattern, name_or_id, flags=re.I):
|
|
180
|
+
w = mgr.get_workspace(name_or_id)
|
|
181
|
+
else:
|
|
182
|
+
w = mgr.get_workspace_group(workspace_group_id).workspaces[
|
|
183
|
+
name_or_id
|
|
184
|
+
]
|
|
185
|
+
else:
|
|
186
|
+
# String: workspace_name_or_id (existing behavior)
|
|
187
|
+
name_or_id = workspace_spec
|
|
188
|
+
uuid_pattern = (
|
|
189
|
+
r'[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}'
|
|
190
|
+
)
|
|
191
|
+
if re.match(uuid_pattern, name_or_id, flags=re.I):
|
|
192
|
+
w = mgr.get_workspace(name_or_id)
|
|
193
|
+
else:
|
|
194
|
+
w = mgr.get_workspace_group(
|
|
195
|
+
self.workspace_group_id,
|
|
196
|
+
).workspaces[name_or_id]
|
|
197
|
+
|
|
198
|
+
if w.state and w.state.lower() not in ['active', 'resumed']:
|
|
199
|
+
raise RuntimeError('workspace is not active')
|
|
200
|
+
|
|
201
|
+
id = w.id
|
|
202
|
+
|
|
203
|
+
self._call_javascript(
|
|
204
|
+
'changeDeployment', [id],
|
|
205
|
+
wait_on_condition=lambda: self.workspace_id == id, # type: ignore
|
|
206
|
+
timeout_message='timeout waiting for workspace update',
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
deployment = workspace
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def connection(self) -> Tuple[obj.Workspace, Optional[str]]:
|
|
213
|
+
"""Workspace and default database name."""
|
|
214
|
+
return self.workspace, self.default_database
|
|
215
|
+
|
|
216
|
+
@connection.setter
|
|
217
|
+
def connection(
|
|
218
|
+
self,
|
|
219
|
+
connection_spec: Union[Tuple[str, str], Tuple[str, str, str]],
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Set workspace and default database name."""
|
|
222
|
+
if len(connection_spec) == 3:
|
|
223
|
+
# 3-element tuple: (workspace_group_id, workspace_name_or_id,
|
|
224
|
+
# default_database)
|
|
225
|
+
workspace_group_id, name_or_id, default_database = connection_spec
|
|
226
|
+
uuid_pattern = (
|
|
227
|
+
r'[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}'
|
|
228
|
+
)
|
|
229
|
+
if re.match(uuid_pattern, name_or_id, flags=re.I):
|
|
230
|
+
w = mgr.get_workspace(name_or_id)
|
|
231
|
+
else:
|
|
232
|
+
w = mgr.get_workspace_group(workspace_group_id).workspaces[
|
|
233
|
+
name_or_id
|
|
234
|
+
]
|
|
235
|
+
else:
|
|
236
|
+
# 2-element tuple: (workspace_name_or_id, default_database)
|
|
237
|
+
# existing behavior
|
|
238
|
+
name_or_id, default_database = connection_spec
|
|
239
|
+
uuid_pattern = (
|
|
240
|
+
r'[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}'
|
|
241
|
+
)
|
|
242
|
+
if re.match(uuid_pattern, name_or_id, flags=re.I):
|
|
243
|
+
w = mgr.get_workspace(name_or_id)
|
|
244
|
+
else:
|
|
245
|
+
w = mgr.get_workspace_group(
|
|
246
|
+
self.workspace_group_id,
|
|
247
|
+
).workspaces[name_or_id]
|
|
248
|
+
|
|
249
|
+
if w.state and w.state.lower() not in ['active', 'resumed']:
|
|
250
|
+
raise RuntimeError('workspace is not active')
|
|
251
|
+
|
|
252
|
+
id = w.id
|
|
253
|
+
|
|
254
|
+
self._call_javascript(
|
|
255
|
+
'changeConnection', [id, default_database],
|
|
256
|
+
wait_on_condition=lambda: self.workspace_id == id and
|
|
257
|
+
self.default_database == default_database, # type: ignore
|
|
258
|
+
timeout_message='timeout waiting for workspace update',
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def cluster_id(self) -> Optional[str]:
|
|
263
|
+
"""Cluster ID."""
|
|
264
|
+
try:
|
|
265
|
+
return self._connection_info['cluster']
|
|
266
|
+
except KeyError:
|
|
267
|
+
return os.environ.get('SINGLESTOREDB_CLUSTER')
|
|
268
|
+
|
|
269
|
+
def _parse_url(self) -> Dict[str, Any]:
|
|
270
|
+
url = urllib.parse.urlparse(
|
|
271
|
+
os.environ.get('SINGLESTOREDB_URL', ''),
|
|
272
|
+
)
|
|
273
|
+
return dict(
|
|
274
|
+
host=url.hostname or None,
|
|
275
|
+
port=url.port or None,
|
|
276
|
+
user=url.username or None,
|
|
277
|
+
password=url.password or None,
|
|
278
|
+
default_database=url.path.split('/')[-1] or None,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def connection_url(self) -> Optional[str]:
|
|
283
|
+
"""Connection URL."""
|
|
284
|
+
try:
|
|
285
|
+
return self._connection_info['connection_url']
|
|
286
|
+
except KeyError:
|
|
287
|
+
return os.environ.get('SINGLESTOREDB_URL')
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def connection_url_kai(self) -> Optional[str]:
|
|
291
|
+
"""Kai connectionURL."""
|
|
292
|
+
try:
|
|
293
|
+
return self._connection_info.get('connection_url_kai')
|
|
294
|
+
except KeyError:
|
|
295
|
+
return os.environ.get('SINGLESTOREDB_URL_KAI')
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def host(self) -> Optional[str]:
|
|
299
|
+
"""Hostname."""
|
|
300
|
+
try:
|
|
301
|
+
return self._connection_info['host']
|
|
302
|
+
except KeyError:
|
|
303
|
+
return self._parse_url()['host']
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def port(self) -> Optional[int]:
|
|
307
|
+
"""Database server port."""
|
|
308
|
+
try:
|
|
309
|
+
return self._connection_info['port']
|
|
310
|
+
except KeyError:
|
|
311
|
+
return self._parse_url()['port']
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def user(self) -> Optional[str]:
|
|
315
|
+
"""Username."""
|
|
316
|
+
try:
|
|
317
|
+
return self._authentication_info['user']
|
|
318
|
+
except KeyError:
|
|
319
|
+
return self._parse_url()['user']
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def password(self) -> Optional[str]:
|
|
323
|
+
"""Password."""
|
|
324
|
+
try:
|
|
325
|
+
return self._authentication_info['password']
|
|
326
|
+
except KeyError:
|
|
327
|
+
return self._parse_url()['password']
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def default_database(self) -> Optional[str]:
|
|
331
|
+
"""Default database."""
|
|
332
|
+
try:
|
|
333
|
+
return self._connection_info['default_database']
|
|
334
|
+
except KeyError:
|
|
335
|
+
return self._parse_url()['default_database']
|
|
336
|
+
|
|
337
|
+
@default_database.setter
|
|
338
|
+
def default_database(self, name: str) -> None:
|
|
339
|
+
"""Set default database."""
|
|
340
|
+
self._call_javascript(
|
|
341
|
+
'changeDefaultDatabase', [name],
|
|
342
|
+
wait_on_condition=lambda: self.default_database == name, # type: ignore
|
|
343
|
+
timeout_message='timeout waiting for database update',
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def version(self) -> Optional[str]:
|
|
348
|
+
"""Version."""
|
|
349
|
+
return self._connection_info.get('version')
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
portal = Portal()
|
singlestoredb/py.typed
ADDED
|
File without changes
|
singlestoredb/pytest.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Pytest plugin"""
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from . import connect
|
|
16
|
+
from .connection import Connection
|
|
17
|
+
from .connection import Cursor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# How many times to attempt to connect to the container
|
|
24
|
+
STARTUP_CONNECT_ATTEMPTS = 10
|
|
25
|
+
# How long to wait between connection attempts
|
|
26
|
+
STARTUP_CONNECT_TIMEOUT_SECONDS = 2
|
|
27
|
+
# How many times to check if all connections are closed
|
|
28
|
+
TEARDOWN_WAIT_ATTEMPTS = 20
|
|
29
|
+
# How long to wait between checking connections
|
|
30
|
+
TEARDOWN_WAIT_SECONDS = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _find_free_port() -> int:
|
|
34
|
+
"""Find a free port by binding to port 0 and getting the assigned port."""
|
|
35
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
36
|
+
s.bind(('', 0))
|
|
37
|
+
s.listen(1)
|
|
38
|
+
return s.getsockname()[1]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExecutionMode(Enum):
|
|
42
|
+
SEQUENTIAL = 1
|
|
43
|
+
LEADER = 2
|
|
44
|
+
FOLLOWER = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture(scope='session')
|
|
48
|
+
def execution_mode() -> ExecutionMode:
|
|
49
|
+
"""Determine the pytest mode for this process"""
|
|
50
|
+
|
|
51
|
+
worker = os.environ.get('PYTEST_XDIST_WORKER')
|
|
52
|
+
worker_count = os.environ.get('PYTEST_XDIST_WORKER_COUNT')
|
|
53
|
+
|
|
54
|
+
# If we're not in pytest-xdist, the mode is Sequential
|
|
55
|
+
if worker is None or worker_count is None:
|
|
56
|
+
logger.debug('XDIST environment vars not found')
|
|
57
|
+
return ExecutionMode.SEQUENTIAL
|
|
58
|
+
|
|
59
|
+
logger.debug(f'PYTEST_XDIST_WORKER == {worker}')
|
|
60
|
+
logger.debug(f'PYTEST_XDIST_WORKER_COUNT == {worker_count}')
|
|
61
|
+
|
|
62
|
+
# If we're the only worker, than the mode is Sequential
|
|
63
|
+
if worker_count == '1':
|
|
64
|
+
return ExecutionMode.SEQUENTIAL
|
|
65
|
+
else:
|
|
66
|
+
# The first worker (named "gw0") is the leader
|
|
67
|
+
# if there are multiple workers
|
|
68
|
+
if worker == 'gw0':
|
|
69
|
+
return ExecutionMode.LEADER
|
|
70
|
+
else:
|
|
71
|
+
return ExecutionMode.FOLLOWER
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.fixture(scope='session')
|
|
75
|
+
def node_name() -> Iterator[str]:
|
|
76
|
+
"""Determine the name of this worker node"""
|
|
77
|
+
|
|
78
|
+
worker = os.environ.get('PYTEST_XDIST_WORKER')
|
|
79
|
+
|
|
80
|
+
if worker is None:
|
|
81
|
+
logger.debug('XDIST environment vars not found')
|
|
82
|
+
yield 'master'
|
|
83
|
+
else:
|
|
84
|
+
logger.debug(f'PYTEST_XDIST_WORKER == {worker}')
|
|
85
|
+
yield worker
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _TestContainerManager():
|
|
89
|
+
"""Manages the setup and teardown of a SingleStoreDB Dev Container"""
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
# Generate unique container name using UUID and worker ID
|
|
93
|
+
worker = os.environ.get('PYTEST_XDIST_WORKER', 'master')
|
|
94
|
+
unique_id = uuid.uuid4().hex[:8]
|
|
95
|
+
self.container_name = f'singlestoredb-test-{worker}-{unique_id}'
|
|
96
|
+
|
|
97
|
+
self.dev_image_name = 'ghcr.io/singlestore-labs/singlestoredb-dev'
|
|
98
|
+
|
|
99
|
+
assert 'SINGLESTORE_LICENSE' in os.environ, 'SINGLESTORE_LICENSE not set'
|
|
100
|
+
|
|
101
|
+
self.root_password = 'Q8r4D7yXR8oqn'
|
|
102
|
+
self.environment_vars = {
|
|
103
|
+
'SINGLESTORE_LICENSE': None,
|
|
104
|
+
'ROOT_PASSWORD': f"\"{self.root_password}\"",
|
|
105
|
+
'SINGLESTORE_SET_GLOBAL_DEFAULT_PARTITIONS_PER_LEAF': '1',
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Use dynamic port allocation to avoid conflicts
|
|
109
|
+
self.mysql_port = _find_free_port()
|
|
110
|
+
self.http_port = _find_free_port()
|
|
111
|
+
self.studio_port = _find_free_port()
|
|
112
|
+
self.ports = [
|
|
113
|
+
(self.mysql_port, '3306'), # External port -> Internal port
|
|
114
|
+
(self.http_port, '8080'),
|
|
115
|
+
(self.studio_port, '9000'),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
self.url = f'root:{self.root_password}@127.0.0.1:{self.mysql_port}'
|
|
119
|
+
|
|
120
|
+
def _container_exists(self) -> bool:
|
|
121
|
+
"""Check if a container with this name already exists."""
|
|
122
|
+
try:
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
[
|
|
125
|
+
'docker', 'ps', '-a', '--filter',
|
|
126
|
+
f'name={self.container_name}',
|
|
127
|
+
'--format', '{{.Names}}',
|
|
128
|
+
],
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
check=True,
|
|
132
|
+
)
|
|
133
|
+
return self.container_name in result.stdout
|
|
134
|
+
except subprocess.CalledProcessError:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def _cleanup_existing_container(self) -> None:
|
|
138
|
+
"""Stop and remove any existing container with the same name."""
|
|
139
|
+
if not self._container_exists():
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
logger.info(f'Found existing container {self.container_name}, cleaning up')
|
|
143
|
+
try:
|
|
144
|
+
# Try to stop the container (ignore if it's already stopped)
|
|
145
|
+
subprocess.run(
|
|
146
|
+
['docker', 'stop', self.container_name],
|
|
147
|
+
capture_output=True,
|
|
148
|
+
check=False,
|
|
149
|
+
)
|
|
150
|
+
# Remove the container
|
|
151
|
+
subprocess.run(
|
|
152
|
+
['docker', 'rm', self.container_name],
|
|
153
|
+
capture_output=True,
|
|
154
|
+
check=True,
|
|
155
|
+
)
|
|
156
|
+
logger.debug(f'Cleaned up existing container {self.container_name}')
|
|
157
|
+
except subprocess.CalledProcessError as e:
|
|
158
|
+
logger.warning(f'Failed to cleanup existing container: {e}')
|
|
159
|
+
# Continue anyway - the unique name should prevent most conflicts
|
|
160
|
+
|
|
161
|
+
def start(self) -> None:
|
|
162
|
+
# Clean up any existing container with the same name
|
|
163
|
+
self._cleanup_existing_container()
|
|
164
|
+
|
|
165
|
+
command = ' '.join(self._start_command())
|
|
166
|
+
|
|
167
|
+
logger.info(
|
|
168
|
+
f'Starting container {self.container_name} on ports {self.mysql_port}, '
|
|
169
|
+
f'{self.http_port}, {self.studio_port}',
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
license = os.environ['SINGLESTORE_LICENSE']
|
|
173
|
+
env = {
|
|
174
|
+
'SINGLESTORE_LICENSE': license,
|
|
175
|
+
}
|
|
176
|
+
subprocess.check_call(command, shell=True, env=env)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.exception(e)
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
f'Failed to start container {self.container_name}. '
|
|
181
|
+
f'Command: {command}',
|
|
182
|
+
) from e
|
|
183
|
+
logger.debug('Container started')
|
|
184
|
+
|
|
185
|
+
def _start_command(self) -> Iterator[str]:
|
|
186
|
+
yield 'docker run -d --name'
|
|
187
|
+
yield self.container_name
|
|
188
|
+
for key, value in self.environment_vars.items():
|
|
189
|
+
yield '-e'
|
|
190
|
+
if value is None:
|
|
191
|
+
yield key
|
|
192
|
+
else:
|
|
193
|
+
yield f'{key}={value}'
|
|
194
|
+
|
|
195
|
+
for external_port, internal_port in self.ports:
|
|
196
|
+
yield '-p'
|
|
197
|
+
yield f'{external_port}:{internal_port}'
|
|
198
|
+
|
|
199
|
+
yield self.dev_image_name
|
|
200
|
+
|
|
201
|
+
def print_logs(self) -> None:
|
|
202
|
+
logs_command = ['docker', 'logs', self.container_name]
|
|
203
|
+
logger.info('Getting logs')
|
|
204
|
+
logger.info(subprocess.check_output(logs_command))
|
|
205
|
+
|
|
206
|
+
def connect(self) -> Connection:
|
|
207
|
+
# Run all but one attempts trying again if they fail
|
|
208
|
+
for i in range(STARTUP_CONNECT_ATTEMPTS - 1):
|
|
209
|
+
try:
|
|
210
|
+
return connect(self.url)
|
|
211
|
+
except Exception:
|
|
212
|
+
logger.debug(f'Database not available yet (attempt #{i}).')
|
|
213
|
+
time.sleep(STARTUP_CONNECT_TIMEOUT_SECONDS)
|
|
214
|
+
else:
|
|
215
|
+
# Try one last time and report error if it fails
|
|
216
|
+
try:
|
|
217
|
+
return connect(self.url)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.error('Timed out while waiting to connect to database.')
|
|
220
|
+
logger.exception(e)
|
|
221
|
+
self.print_logs()
|
|
222
|
+
raise RuntimeError('Failed to connect to database') from e
|
|
223
|
+
|
|
224
|
+
def wait_till_connections_closed(self) -> None:
|
|
225
|
+
heart_beat = connect(self.url)
|
|
226
|
+
for i in range(TEARDOWN_WAIT_ATTEMPTS):
|
|
227
|
+
connections = self.get_open_connections(heart_beat)
|
|
228
|
+
if connections is None:
|
|
229
|
+
raise RuntimeError('Could not determine the number of open connections.')
|
|
230
|
+
logger.debug(
|
|
231
|
+
f'Waiting for other connections (n={connections-1}) '
|
|
232
|
+
f'to close (attempt #{i})',
|
|
233
|
+
)
|
|
234
|
+
time.sleep(TEARDOWN_WAIT_SECONDS)
|
|
235
|
+
else:
|
|
236
|
+
logger.warning('Timed out while waiting for other connections to close')
|
|
237
|
+
self.print_logs()
|
|
238
|
+
|
|
239
|
+
def get_open_connections(self, conn: Connection) -> Optional[int]:
|
|
240
|
+
for row in conn.show.status(extended=True):
|
|
241
|
+
name = row['Name']
|
|
242
|
+
value = row['Value']
|
|
243
|
+
logger.info(f'{name} = {value}')
|
|
244
|
+
if name == 'Threads_connected':
|
|
245
|
+
return int(value)
|
|
246
|
+
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def stop(self) -> None:
|
|
250
|
+
logger.info('Cleaning up SingleStore DB dev container')
|
|
251
|
+
logger.debug('Stopping container')
|
|
252
|
+
try:
|
|
253
|
+
subprocess.check_call(f'docker stop {self.container_name}', shell=True)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.exception(e)
|
|
256
|
+
raise RuntimeError('Failed to stop container.') from e
|
|
257
|
+
|
|
258
|
+
logger.debug('Removing container')
|
|
259
|
+
try:
|
|
260
|
+
subprocess.check_call(f'docker rm {self.container_name}', shell=True)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.exception(e)
|
|
263
|
+
raise RuntimeError('Failed to stop container.') from e
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@pytest.fixture(scope='session')
|
|
267
|
+
def singlestoredb_test_container(
|
|
268
|
+
execution_mode: ExecutionMode,
|
|
269
|
+
) -> Iterator[_TestContainerManager]:
|
|
270
|
+
"""Sets up and tears down the test container"""
|
|
271
|
+
|
|
272
|
+
if not isinstance(execution_mode, ExecutionMode):
|
|
273
|
+
raise TypeError(f"Invalid execution mode '{execution_mode}'")
|
|
274
|
+
|
|
275
|
+
container_manager = _TestContainerManager()
|
|
276
|
+
|
|
277
|
+
# In sequential operation do all the steps
|
|
278
|
+
if execution_mode == ExecutionMode.SEQUENTIAL:
|
|
279
|
+
logger.debug('Not distributed')
|
|
280
|
+
container_manager.start()
|
|
281
|
+
yield container_manager
|
|
282
|
+
container_manager.stop()
|
|
283
|
+
|
|
284
|
+
# In distributed execution as leader,
|
|
285
|
+
# do the steps but wait for other workers before stopping
|
|
286
|
+
elif execution_mode == ExecutionMode.LEADER:
|
|
287
|
+
logger.debug('Distributed leader')
|
|
288
|
+
container_manager.start()
|
|
289
|
+
yield container_manager
|
|
290
|
+
container_manager.wait_till_connections_closed()
|
|
291
|
+
container_manager.stop()
|
|
292
|
+
|
|
293
|
+
# In distributed exeuction as a non-leader,
|
|
294
|
+
# don't worry about the container lifecycle
|
|
295
|
+
elif execution_mode == ExecutionMode.FOLLOWER:
|
|
296
|
+
logger.debug('Distributed follower')
|
|
297
|
+
yield container_manager
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@pytest.fixture(scope='session')
|
|
301
|
+
def singlestoredb_connection(
|
|
302
|
+
singlestoredb_test_container: _TestContainerManager,
|
|
303
|
+
) -> Iterator[Connection]:
|
|
304
|
+
"""Creates and closes the connection"""
|
|
305
|
+
|
|
306
|
+
connection = singlestoredb_test_container.connect()
|
|
307
|
+
logger.debug('Connected to database.')
|
|
308
|
+
|
|
309
|
+
yield connection
|
|
310
|
+
|
|
311
|
+
logger.debug('Closing connection')
|
|
312
|
+
connection.close()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _NameAllocator():
|
|
316
|
+
"""Generates unique names for each database"""
|
|
317
|
+
|
|
318
|
+
def __init__(self, id: str) -> None:
|
|
319
|
+
self.id = id
|
|
320
|
+
self.names = 0
|
|
321
|
+
|
|
322
|
+
def get_name(self) -> str:
|
|
323
|
+
name = f'x_db_{self.id}_{self.names}'
|
|
324
|
+
self.names += 1
|
|
325
|
+
return name
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@pytest.fixture(scope='session')
|
|
329
|
+
def name_allocator(node_name: str) -> Iterator[_NameAllocator]:
|
|
330
|
+
"""Makes a worker-local name allocator using the node name"""
|
|
331
|
+
|
|
332
|
+
yield _NameAllocator(node_name)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@pytest.fixture
|
|
336
|
+
def singlestoredb_tempdb(
|
|
337
|
+
singlestoredb_connection: Connection, name_allocator: _NameAllocator,
|
|
338
|
+
) -> Iterator[Cursor]:
|
|
339
|
+
"""Provides a connection to a unique temporary test database"""
|
|
340
|
+
|
|
341
|
+
assert singlestoredb_connection.is_connected(), 'Database is no longer connected'
|
|
342
|
+
db = name_allocator.get_name()
|
|
343
|
+
|
|
344
|
+
with singlestoredb_connection.cursor() as cursor:
|
|
345
|
+
logger.debug(f"Creating temporary DB \"{db}\"")
|
|
346
|
+
cursor.execute(f'CREATE DATABASE {db}')
|
|
347
|
+
cursor.execute(f'USE {db}')
|
|
348
|
+
|
|
349
|
+
yield cursor
|
|
350
|
+
|
|
351
|
+
logger.debug(f"Dropping temporary DB \"{db}\"")
|
|
352
|
+
cursor.execute(f'DROP DATABASE {db}')
|
|
File without changes
|