singlestoredb 0.4.0__py3-none-any.whl → 1.0.4__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.
Potentially problematic release.
This version of singlestoredb might be problematic. Click here for more details.
- singlestoredb/__init__.py +33 -1
- singlestoredb/alchemy/__init__.py +90 -0
- singlestoredb/auth.py +5 -1
- singlestoredb/config.py +116 -14
- singlestoredb/connection.py +483 -516
- singlestoredb/converters.py +238 -135
- singlestoredb/exceptions.py +30 -2
- singlestoredb/functions/__init__.py +1 -0
- singlestoredb/functions/decorator.py +142 -0
- singlestoredb/functions/dtypes.py +1639 -0
- singlestoredb/functions/ext/__init__.py +2 -0
- singlestoredb/functions/ext/arrow.py +375 -0
- singlestoredb/functions/ext/asgi.py +661 -0
- singlestoredb/functions/ext/json.py +427 -0
- singlestoredb/functions/ext/mmap.py +306 -0
- singlestoredb/functions/ext/rowdat_1.py +744 -0
- singlestoredb/functions/signature.py +673 -0
- singlestoredb/fusion/__init__.py +11 -0
- singlestoredb/fusion/graphql.py +213 -0
- singlestoredb/fusion/handler.py +621 -0
- singlestoredb/fusion/handlers/stage.py +257 -0
- singlestoredb/fusion/handlers/utils.py +162 -0
- singlestoredb/fusion/handlers/workspace.py +412 -0
- singlestoredb/fusion/registry.py +164 -0
- singlestoredb/fusion/result.py +399 -0
- singlestoredb/http/__init__.py +27 -0
- singlestoredb/{http.py → http/connection.py} +555 -154
- singlestoredb/management/__init__.py +3 -0
- singlestoredb/management/billing_usage.py +148 -0
- singlestoredb/management/cluster.py +14 -6
- singlestoredb/management/manager.py +100 -38
- singlestoredb/management/organization.py +188 -0
- singlestoredb/management/region.py +5 -5
- singlestoredb/management/utils.py +281 -2
- singlestoredb/management/workspace.py +1344 -49
- singlestoredb/{clients/pymysqlsv → mysql}/__init__.py +16 -21
- singlestoredb/{clients/pymysqlsv → mysql}/_auth.py +39 -8
- singlestoredb/{clients/pymysqlsv → mysql}/charset.py +26 -23
- singlestoredb/{clients/pymysqlsv/connections.py → mysql/connection.py} +532 -165
- singlestoredb/{clients/pymysqlsv → mysql}/constants/CLIENT.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/COMMAND.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/CR.py +0 -2
- singlestoredb/{clients/pymysqlsv → mysql}/constants/ER.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/FIELD_TYPE.py +1 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/FLAG.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/SERVER_STATUS.py +0 -1
- singlestoredb/mysql/converters.py +271 -0
- singlestoredb/{clients/pymysqlsv → mysql}/cursors.py +228 -112
- singlestoredb/mysql/err.py +92 -0
- singlestoredb/{clients/pymysqlsv → mysql}/optionfile.py +5 -4
- singlestoredb/{clients/pymysqlsv → mysql}/protocol.py +49 -20
- singlestoredb/mysql/tests/__init__.py +19 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/base.py +32 -12
- singlestoredb/mysql/tests/conftest.py +37 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_DictCursor.py +11 -7
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_SSCursor.py +17 -12
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_basic.py +32 -24
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_connection.py +130 -119
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_converters.py +9 -7
- singlestoredb/mysql/tests/test_cursor.py +141 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_err.py +3 -2
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_issues.py +35 -27
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_load_local.py +13 -11
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_nextset.py +7 -3
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_optionfile.py +2 -1
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/__init__.py +1 -1
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/__init__.py +9 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/capabilities.py +19 -17
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/dbapi20.py +31 -22
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py +3 -4
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py +24 -20
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py +4 -4
- singlestoredb/{clients/pymysqlsv → mysql}/times.py +3 -4
- singlestoredb/pytest.py +283 -0
- singlestoredb/tests/empty.sql +0 -0
- singlestoredb/tests/ext_funcs/__init__.py +385 -0
- singlestoredb/tests/test.sql +210 -0
- singlestoredb/tests/test2.sql +1 -0
- singlestoredb/tests/test_basics.py +482 -115
- singlestoredb/tests/test_config.py +13 -13
- singlestoredb/tests/test_connection.py +241 -305
- singlestoredb/tests/test_dbapi.py +27 -0
- singlestoredb/tests/test_ext_func.py +1193 -0
- singlestoredb/tests/test_ext_func_data.py +1101 -0
- singlestoredb/tests/test_fusion.py +465 -0
- singlestoredb/tests/test_http.py +32 -26
- singlestoredb/tests/test_management.py +588 -8
- singlestoredb/tests/test_plugin.py +33 -0
- singlestoredb/tests/test_results.py +11 -12
- singlestoredb/tests/test_udf.py +687 -0
- singlestoredb/tests/utils.py +3 -2
- singlestoredb/utils/config.py +58 -0
- singlestoredb/utils/debug.py +13 -0
- singlestoredb/utils/mogrify.py +151 -0
- singlestoredb/utils/results.py +4 -1
- singlestoredb-1.0.4.dist-info/METADATA +139 -0
- singlestoredb-1.0.4.dist-info/RECORD +112 -0
- {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/WHEEL +1 -1
- singlestoredb-1.0.4.dist-info/entry_points.txt +2 -0
- singlestoredb/clients/pymysqlsv/converters.py +0 -365
- singlestoredb/clients/pymysqlsv/err.py +0 -144
- singlestoredb/clients/pymysqlsv/tests/__init__.py +0 -19
- singlestoredb/clients/pymysqlsv/tests/test_cursor.py +0 -133
- singlestoredb/clients/pymysqlsv/tests/thirdparty/test_MySQLdb/__init__.py +0 -9
- singlestoredb/drivers/__init__.py +0 -45
- singlestoredb/drivers/base.py +0 -198
- singlestoredb/drivers/cymysql.py +0 -38
- singlestoredb/drivers/http.py +0 -47
- singlestoredb/drivers/mariadb.py +0 -40
- singlestoredb/drivers/mysqlconnector.py +0 -49
- singlestoredb/drivers/mysqldb.py +0 -60
- singlestoredb/drivers/pymysql.py +0 -37
- singlestoredb/drivers/pymysqlsv.py +0 -35
- singlestoredb/drivers/pyodbc.py +0 -65
- singlestoredb-0.4.0.dist-info/METADATA +0 -111
- singlestoredb-0.4.0.dist-info/RECORD +0 -86
- /singlestoredb/{clients → fusion/handlers}/__init__.py +0 -0
- /singlestoredb/{clients/pymysqlsv → mysql}/constants/__init__.py +0 -0
- {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/LICENSE +0 -0
- {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/top_level.txt +0 -0
|
@@ -1,11 +1,167 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
"""SingleStoreDB Cluster Management."""
|
|
3
3
|
import datetime
|
|
4
|
+
import functools
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
4
8
|
from typing import Any
|
|
9
|
+
from typing import Callable
|
|
10
|
+
from typing import Dict
|
|
11
|
+
from typing import List
|
|
12
|
+
from typing import Mapping
|
|
5
13
|
from typing import Optional
|
|
14
|
+
from typing import SupportsIndex
|
|
15
|
+
from typing import TypeVar
|
|
6
16
|
from typing import Union
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import jwt
|
|
7
20
|
|
|
8
21
|
from .. import converters
|
|
22
|
+
from ..config import get_option
|
|
23
|
+
|
|
24
|
+
JSON = Union[str, List[str], Dict[str, 'JSON']]
|
|
25
|
+
JSONObj = Dict[str, JSON]
|
|
26
|
+
JSONList = List[JSON]
|
|
27
|
+
T = TypeVar('T')
|
|
28
|
+
|
|
29
|
+
if sys.version_info < (3, 10):
|
|
30
|
+
PathLike = Union[str, os.PathLike]
|
|
31
|
+
PathLikeABC = os.PathLike
|
|
32
|
+
else:
|
|
33
|
+
PathLike = Union[str, os.PathLike[str]]
|
|
34
|
+
PathLikeABC = os.PathLike[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TTLProperty(object):
|
|
38
|
+
"""Property with time limit."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, fget: Callable[[Any], Any], ttl: datetime.timedelta):
|
|
41
|
+
self.fget = fget
|
|
42
|
+
self.ttl = ttl
|
|
43
|
+
self._last_executed = datetime.datetime(2000, 1, 1)
|
|
44
|
+
self._last_result = None
|
|
45
|
+
self.__doc__ = fget.__doc__
|
|
46
|
+
self._name = ''
|
|
47
|
+
|
|
48
|
+
def reset(self) -> None:
|
|
49
|
+
self._last_executed = datetime.datetime(2000, 1, 1)
|
|
50
|
+
self._last_result = None
|
|
51
|
+
|
|
52
|
+
def __set_name__(self, owner: Any, name: str) -> None:
|
|
53
|
+
self._name = name
|
|
54
|
+
|
|
55
|
+
def __get__(self, obj: Any, objtype: Any = None) -> Any:
|
|
56
|
+
if obj is None:
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
if self._last_result is not None \
|
|
60
|
+
and (datetime.datetime.now() - self._last_executed) < self.ttl:
|
|
61
|
+
return self._last_result
|
|
62
|
+
|
|
63
|
+
self._last_result = self.fget(obj)
|
|
64
|
+
self._last_executed = datetime.datetime.now()
|
|
65
|
+
|
|
66
|
+
return self._last_result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ttl_property(ttl: datetime.timedelta) -> Callable[[Any], Any]:
|
|
70
|
+
"""Property with a time-to-live."""
|
|
71
|
+
def wrapper(func: Callable[[Any], Any]) -> Any:
|
|
72
|
+
out = TTLProperty(func, ttl=ttl)
|
|
73
|
+
return functools.wraps(func)(out)
|
|
74
|
+
return wrapper
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NamedList(List[T]):
|
|
78
|
+
"""List class which also allows selection by ``name`` and ``id`` attribute."""
|
|
79
|
+
|
|
80
|
+
def _find_item(self, key: str) -> T:
|
|
81
|
+
for item in self:
|
|
82
|
+
if getattr(item, 'name', '') == key:
|
|
83
|
+
return item
|
|
84
|
+
if getattr(item, 'id', '') == key:
|
|
85
|
+
return item
|
|
86
|
+
raise KeyError(key)
|
|
87
|
+
|
|
88
|
+
def __getitem__(self, key: Union[SupportsIndex, slice, str]) -> Any:
|
|
89
|
+
if isinstance(key, str):
|
|
90
|
+
return self._find_item(key)
|
|
91
|
+
return super().__getitem__(key)
|
|
92
|
+
|
|
93
|
+
def __contains__(self, key: Any) -> bool:
|
|
94
|
+
if isinstance(key, str):
|
|
95
|
+
try:
|
|
96
|
+
self._find_item(key)
|
|
97
|
+
return True
|
|
98
|
+
except KeyError:
|
|
99
|
+
return False
|
|
100
|
+
return super().__contains__(key)
|
|
101
|
+
|
|
102
|
+
def names(self) -> List[str]:
|
|
103
|
+
"""Return ``name`` attribute of each item."""
|
|
104
|
+
return [y for y in [getattr(x, 'name', None) for x in self] if y is not None]
|
|
105
|
+
|
|
106
|
+
def ids(self) -> List[str]:
|
|
107
|
+
"""Return ``id`` attribute of each item."""
|
|
108
|
+
return [y for y in [getattr(x, 'id', None) for x in self] if y is not None]
|
|
109
|
+
|
|
110
|
+
def get(self, name_or_id: str, *default: Any) -> Any:
|
|
111
|
+
"""Return object with name / ID if it exists, or return default value."""
|
|
112
|
+
try:
|
|
113
|
+
return self._find_item(name_or_id)
|
|
114
|
+
except KeyError:
|
|
115
|
+
if default:
|
|
116
|
+
return default[0]
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_token() -> Optional[str]:
|
|
121
|
+
"""Return the token for the Management API."""
|
|
122
|
+
# See if an API key is configured
|
|
123
|
+
tok = get_option('management.token')
|
|
124
|
+
if tok:
|
|
125
|
+
return tok
|
|
126
|
+
|
|
127
|
+
url = os.environ.get('SINGLESTOREDB_URL')
|
|
128
|
+
if not url:
|
|
129
|
+
# See if the connection URL contains a JWT
|
|
130
|
+
url = get_option('host')
|
|
131
|
+
if not url:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
urlp = urlparse(url, scheme='singlestoredb', allow_fragments=True)
|
|
135
|
+
if urlp.password:
|
|
136
|
+
try:
|
|
137
|
+
jwt.decode(urlp.password, options={'verify_signature': False})
|
|
138
|
+
return urlp.password
|
|
139
|
+
except jwt.DecodeError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
# Didn't find a key anywhere
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_organization() -> Optional[str]:
|
|
147
|
+
"""Return the organization for the current token or environment."""
|
|
148
|
+
org = os.environ.get('SINGLESTOREDB_ORGANIZATION')
|
|
149
|
+
if org:
|
|
150
|
+
return org
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def enable_http_tracing() -> None:
|
|
156
|
+
"""Enable tracing of HTTP requests."""
|
|
157
|
+
import logging
|
|
158
|
+
import http.client as http_client
|
|
159
|
+
http_client.HTTPConnection.debuglevel = 1
|
|
160
|
+
logging.basicConfig()
|
|
161
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
162
|
+
requests_log = logging.getLogger('requests.packages.urllib3')
|
|
163
|
+
requests_log.setLevel(logging.DEBUG)
|
|
164
|
+
requests_log.propagate = True
|
|
9
165
|
|
|
10
166
|
|
|
11
167
|
def to_datetime(
|
|
@@ -16,20 +172,143 @@ def to_datetime(
|
|
|
16
172
|
return None
|
|
17
173
|
if isinstance(obj, datetime.datetime):
|
|
18
174
|
return obj
|
|
175
|
+
if obj == '0001-01-01T00:00:00Z':
|
|
176
|
+
return None
|
|
19
177
|
obj = obj.replace('Z', '')
|
|
20
178
|
# Fix datetimes with truncated zeros
|
|
21
179
|
if '.' in obj:
|
|
22
180
|
obj, micros = obj.split('.', 1)
|
|
23
181
|
micros = micros + '0' * (6 - len(micros))
|
|
24
182
|
obj = obj + '.' + micros
|
|
25
|
-
|
|
183
|
+
out = converters.datetime_fromisoformat(obj)
|
|
184
|
+
if isinstance(out, str):
|
|
185
|
+
return None
|
|
186
|
+
if isinstance(out, datetime.date):
|
|
187
|
+
return datetime.datetime(out.year, out.month, out.day)
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def from_datetime(
|
|
192
|
+
obj: Union[str, datetime.datetime],
|
|
193
|
+
) -> Optional[str]:
|
|
194
|
+
"""Convert datetime to string."""
|
|
195
|
+
if not obj:
|
|
196
|
+
return None
|
|
197
|
+
if isinstance(obj, str):
|
|
198
|
+
return obj
|
|
199
|
+
out = obj.isoformat()
|
|
200
|
+
if not re.search(r'[A-Za-z]$', out):
|
|
201
|
+
out = f'{out}Z'
|
|
202
|
+
return out
|
|
26
203
|
|
|
27
204
|
|
|
28
205
|
def vars_to_str(obj: Any) -> str:
|
|
29
206
|
"""Render a string representation of vars(obj)."""
|
|
30
207
|
attrs = []
|
|
31
|
-
|
|
208
|
+
obj_vars = vars(obj)
|
|
209
|
+
if 'name' in obj_vars:
|
|
210
|
+
attrs.append('name={}'.format(repr(obj_vars['name'])))
|
|
211
|
+
if 'id' in obj_vars:
|
|
212
|
+
attrs.append('id={}'.format(repr(obj_vars['id'])))
|
|
213
|
+
for name, value in sorted(obj_vars.items()):
|
|
214
|
+
if name in ('name', 'id'):
|
|
215
|
+
continue
|
|
32
216
|
if not value or name.startswith('_'):
|
|
33
217
|
continue
|
|
34
218
|
attrs.append('{}={}'.format(name, repr(value)))
|
|
35
219
|
return '{}({})'.format(type(obj).__name__, ', '.join(attrs))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def single_item(s: Any) -> Any:
|
|
223
|
+
"""Return only item if ``s`` is a list, otherwise return ``s``."""
|
|
224
|
+
if isinstance(s, list):
|
|
225
|
+
if len(s) != 1:
|
|
226
|
+
raise ValueError('list must only contain a singleitem')
|
|
227
|
+
return s[0]
|
|
228
|
+
return s
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def stringify(s: JSON) -> str:
|
|
232
|
+
"""Convert list of strings to single string."""
|
|
233
|
+
if isinstance(s, (tuple, list)):
|
|
234
|
+
if len(s) > 1:
|
|
235
|
+
raise ValueError('list contains more than one item')
|
|
236
|
+
return s[0]
|
|
237
|
+
if isinstance(s, dict):
|
|
238
|
+
raise TypeError('only strings and lists are valid arguments')
|
|
239
|
+
return s
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def listify(s: JSON) -> List[str]:
|
|
243
|
+
"""Convert string to list of strings."""
|
|
244
|
+
if isinstance(s, (tuple, list)):
|
|
245
|
+
return list(s)
|
|
246
|
+
if isinstance(s, dict):
|
|
247
|
+
raise TypeError('only strings and lists are valid arguments')
|
|
248
|
+
return [s]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def listify_obj(s: JSON) -> List[JSONObj]:
|
|
252
|
+
"""Convert object to list of objects."""
|
|
253
|
+
if isinstance(s, (tuple, list)):
|
|
254
|
+
for item in s:
|
|
255
|
+
if not isinstance(item, dict):
|
|
256
|
+
raise TypeError('only dicts and lists of dicts are valid parameters')
|
|
257
|
+
return list(s) # type: ignore
|
|
258
|
+
if not isinstance(s, dict):
|
|
259
|
+
raise TypeError('only dicts and lists of dicts are valid parameters')
|
|
260
|
+
return [s]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _upper_match(m: Any) -> str:
|
|
264
|
+
"""Upper-case the first match group."""
|
|
265
|
+
return m.group(1).upper()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def snake_to_camel(s: Optional[str], cap_first: bool = False) -> Optional[str]:
|
|
269
|
+
"""Convert snake-case to camel-case."""
|
|
270
|
+
if s is None:
|
|
271
|
+
return None
|
|
272
|
+
out = re.sub(r'_[A-Za-z]', _upper_match, s.lower())
|
|
273
|
+
if cap_first and out:
|
|
274
|
+
return out[0].upper() + out[1:]
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def camel_to_snake(s: Optional[str]) -> Optional[str]:
|
|
279
|
+
"""Convert camel-case to snake-case."""
|
|
280
|
+
if s is None:
|
|
281
|
+
return None
|
|
282
|
+
out = re.sub(r'([A-Z]+)', r'_\1', s).lower()
|
|
283
|
+
if out and out[0] == '_':
|
|
284
|
+
return out[1:]
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def snake_to_camel_dict(
|
|
289
|
+
s: Optional[Mapping[str, Any]],
|
|
290
|
+
cap_first: bool = False,
|
|
291
|
+
) -> Optional[Dict[str, Any]]:
|
|
292
|
+
"""Convert snake-case keys to camel-case keys."""
|
|
293
|
+
if s is None:
|
|
294
|
+
return None
|
|
295
|
+
out = {}
|
|
296
|
+
for k, v in s.items():
|
|
297
|
+
if isinstance(s, Mapping):
|
|
298
|
+
out[str(snake_to_camel(k))] = snake_to_camel_dict(v, cap_first=cap_first)
|
|
299
|
+
else:
|
|
300
|
+
out[str(snake_to_camel(k))] = v
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def camel_to_snake_dict(s: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
305
|
+
"""Convert camel-case keys to snake-case keys."""
|
|
306
|
+
if s is None:
|
|
307
|
+
return None
|
|
308
|
+
out = {}
|
|
309
|
+
for k, v in s.items():
|
|
310
|
+
if isinstance(s, Mapping):
|
|
311
|
+
out[str(camel_to_snake(k))] = camel_to_snake_dict(v)
|
|
312
|
+
else:
|
|
313
|
+
out[str(camel_to_snake(k))] = v
|
|
314
|
+
return out
|