python-corekit 0.1.0__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.
- corekit/__init__.py +0 -0
- corekit/api/__init__.py +9 -0
- corekit/api/handler.py +76 -0
- corekit/api/responses.py +40 -0
- corekit/api/routers.py +115 -0
- corekit/concurrency/__init__.py +9 -0
- corekit/concurrency/decorators.py +72 -0
- corekit/concurrency/thread_local.py +99 -0
- corekit/concurrency/worker.py +65 -0
- corekit/config/__init__.py +47 -0
- corekit/config/loader.py +153 -0
- corekit/config/settings.py +161 -0
- corekit/config/sources.py +125 -0
- corekit/connections/__init__.py +31 -0
- corekit/connections/connectable.py +212 -0
- corekit/connections/decorators.py +92 -0
- corekit/connections/redis/__init__.py +7 -0
- corekit/connections/redis/connection.py +239 -0
- corekit/connections/registry.py +80 -0
- corekit/connections/sql/__init__.py +10 -0
- corekit/connections/sql/connection.py +342 -0
- corekit/connections/sql/fields/__init__.py +7 -0
- corekit/connections/sql/fields/jsonb.py +67 -0
- corekit/connections/sql/migration/__init__.py +57 -0
- corekit/connections/sql/migration/base.py +40 -0
- corekit/connections/sql/migration/operations.py +416 -0
- corekit/connections/sql/migration/registry.py +166 -0
- corekit/connections/sql/migration/table.py +27 -0
- corekit/connections/sql/query.py +68 -0
- corekit/connections/sql/table.py +96 -0
- corekit/constants.py +45 -0
- corekit/crypto/__init__.py +1 -0
- corekit/crypto/constants.py +7 -0
- corekit/crypto/enum.py +11 -0
- corekit/crypto/hasher.py +89 -0
- corekit/data/__init__.py +81 -0
- corekit/data/dataset.py +340 -0
- corekit/data/expressions/__init__.py +46 -0
- corekit/data/expressions/comparison.py +252 -0
- corekit/data/expressions/expression.py +98 -0
- corekit/data/record.py +147 -0
- corekit/data/stats.py +157 -0
- corekit/decorators/__init__.py +2 -0
- corekit/decorators/exception_handling.py +43 -0
- corekit/decorators/warnings.py +35 -0
- corekit/docker/__init__.py +7 -0
- corekit/docker/watchdog.py +222 -0
- corekit/etl/__init__.py +44 -0
- corekit/etl/connection.py +44 -0
- corekit/etl/extract/__init__.py +0 -0
- corekit/etl/extract/extractor.py +48 -0
- corekit/etl/extract/schemas.py +18 -0
- corekit/etl/load/__init__.py +0 -0
- corekit/etl/load/loader.py +53 -0
- corekit/etl/load/schemas.py +33 -0
- corekit/etl/orchestrator.py +201 -0
- corekit/etl/schemas.py +22 -0
- corekit/etl/transform/__init__.py +0 -0
- corekit/etl/transform/schemas.py +15 -0
- corekit/etl/transform/transformer.py +28 -0
- corekit/events/__init__.py +38 -0
- corekit/events/enum.py +58 -0
- corekit/events/frames.py +51 -0
- corekit/events/models.py +23 -0
- corekit/events/publisher.py +75 -0
- corekit/events/reader.py +132 -0
- corekit/events/sse.py +109 -0
- corekit/events/websocket.py +97 -0
- corekit/exceptions/__init__.py +0 -0
- corekit/exceptions/base.py +45 -0
- corekit/exceptions/custom/__init__.py +0 -0
- corekit/exceptions/http/__init__.py +0 -0
- corekit/exceptions/http/exceptions.py +37 -0
- corekit/exceptions/types.py +17 -0
- corekit/files/__init__.py +25 -0
- corekit/files/base.py +117 -0
- corekit/files/enum.py +30 -0
- corekit/files/json.py +12 -0
- corekit/files/pickle.py +12 -0
- corekit/files/toml.py +43 -0
- corekit/http/__init__.py +0 -0
- corekit/http/client.py +176 -0
- corekit/http/exponential_backoff.py +100 -0
- corekit/http/response.py +12 -0
- corekit/log_monitor/__init__.py +23 -0
- corekit/log_monitor/constants.py +8 -0
- corekit/log_monitor/models.py +150 -0
- corekit/log_monitor/service.py +418 -0
- corekit/notifications/__init__.py +8 -0
- corekit/notifications/base.py +51 -0
- corekit/notifications/models.py +34 -0
- corekit/observability/__init__.py +21 -0
- corekit/observability/benchmarkable.py +12 -0
- corekit/observability/loggable.py +29 -0
- corekit/observability/timing/__init__.py +0 -0
- corekit/observability/timing/constants.py +1 -0
- corekit/observability/timing/split.py +20 -0
- corekit/observability/timing/timer.py +30 -0
- corekit/py.typed +0 -0
- corekit/registry/__init__.py +12 -0
- corekit/registry/registry.py +134 -0
- corekit/schemas/__init__.py +0 -0
- corekit/schemas/dataclasses/__init__.py +0 -0
- corekit/schemas/enum.py +49 -0
- corekit/schemas/models/__init__.py +0 -0
- corekit/schemas/models/arbitrary.py +11 -0
- corekit/schemas/models/date_models.py +18 -0
- corekit/schemas/pydantic/__init__.py +0 -0
- corekit/schemas/pydantic/fields.py +35 -0
- corekit/schemas/types.py +40 -0
- corekit/serialization/__init__.py +0 -0
- corekit/serialization/enum.py +21 -0
- corekit/serialization/serializable.py +42 -0
- corekit/serialization/serializer.py +179 -0
- corekit/utils/__init__.py +5 -0
- corekit/utils/ids.py +5 -0
- corekit/utils/raise_exc.py +8 -0
- corekit/utils/time.py +21 -0
- corekit/utils/validators.py +15 -0
- corekit/utils/void.py +8 -0
- python_corekit-0.1.0.dist-info/METADATA +417 -0
- python_corekit-0.1.0.dist-info/RECORD +125 -0
- python_corekit-0.1.0.dist-info/WHEEL +5 -0
- python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_corekit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redis connections.
|
|
3
|
+
|
|
4
|
+
with RedisConnection() as cache:
|
|
5
|
+
cache.set("user:1", {"name": "Ada"}, ex=3600)
|
|
6
|
+
cache.get("user:1") # -> {"name": "Ada"}
|
|
7
|
+
|
|
8
|
+
Values are JSON-encoded on the way in and decoded on the way out, so ordinary
|
|
9
|
+
Python data round-trips without the caller doing anything. JSON rather than
|
|
10
|
+
pickle: cached data is frequently written by one process and read by another,
|
|
11
|
+
and an unpickle of someone else's bytes executes code.
|
|
12
|
+
|
|
13
|
+
Connection details come from the constructor, then configuration, then a local
|
|
14
|
+
default. Nothing is read at import time.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import redis
|
|
22
|
+
from redis import asyncio as aioredis
|
|
23
|
+
|
|
24
|
+
from corekit.config import get_settings
|
|
25
|
+
from corekit.connections import Connectable
|
|
26
|
+
|
|
27
|
+
__all__ = ["RedisConnection", "RedisNotConnectedError"]
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
DEFAULT_URL = "redis://localhost:6379/0"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RedisNotConnectedError(RuntimeError):
|
|
35
|
+
"""
|
|
36
|
+
Raised when a client is used before its connection is opened.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _encode(value: Any) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Encode a value for storage.
|
|
43
|
+
"""
|
|
44
|
+
return json.dumps(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _decode(raw: Any) -> Any:
|
|
48
|
+
"""
|
|
49
|
+
Decode a stored value, returning it unchanged if it is not JSON.
|
|
50
|
+
|
|
51
|
+
Keys written by other tools are common in a shared Redis, so a value that
|
|
52
|
+
does not parse is returned as-is rather than raising.
|
|
53
|
+
"""
|
|
54
|
+
if raw is None:
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(raw, bytes):
|
|
57
|
+
raw = raw.decode("utf-8", errors="replace")
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(raw)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
return raw
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RedisConnection(Connectable):
|
|
65
|
+
"""
|
|
66
|
+
A Connectable wrapper over a Redis client.
|
|
67
|
+
|
|
68
|
+
The sync client is shared per URL for the life of the process, because a
|
|
69
|
+
Redis client is a connection pool and building one per caller defeats it.
|
|
70
|
+
``_disconnect`` is therefore a no-op: one caller finishing must not close a
|
|
71
|
+
pool others are still using. Async clients are per-instance, since they are
|
|
72
|
+
bound to a running event loop.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
_shared_clients: dict[str, Any] = {}
|
|
76
|
+
|
|
77
|
+
def __init__(self, url: str | None = None, safe: bool = False, **kwargs: Any) -> None:
|
|
78
|
+
"""
|
|
79
|
+
:param url: a redis:// URL. Falls back to configuration, then localhost.
|
|
80
|
+
:param safe: log connection failures instead of raising.
|
|
81
|
+
"""
|
|
82
|
+
super().__init__(**kwargs)
|
|
83
|
+
self._url = url or get_settings().redis.url or DEFAULT_URL
|
|
84
|
+
self._safe = safe
|
|
85
|
+
self._async_client: Any = None
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def url(self) -> str:
|
|
89
|
+
"""
|
|
90
|
+
The URL this connection was built with.
|
|
91
|
+
"""
|
|
92
|
+
return self._url
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_connected(self) -> bool:
|
|
96
|
+
return RedisConnection._shared_clients.get(self._url) is not None
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def is_async_connected(self) -> bool:
|
|
100
|
+
return self._async_client is not None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def client(self) -> Any:
|
|
104
|
+
"""
|
|
105
|
+
The sync client, raising if the connection was never opened.
|
|
106
|
+
"""
|
|
107
|
+
existing = RedisConnection._shared_clients.get(self._url)
|
|
108
|
+
if existing is None:
|
|
109
|
+
raise RedisNotConnectedError("RedisConnection is not connected. Call connect() or use a with block.")
|
|
110
|
+
return existing
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def async_client(self) -> Any:
|
|
114
|
+
"""
|
|
115
|
+
The async client, raising if the async connection was never opened.
|
|
116
|
+
"""
|
|
117
|
+
if self._async_client is None:
|
|
118
|
+
raise RedisNotConnectedError("RedisConnection async client is not connected.")
|
|
119
|
+
return self._async_client
|
|
120
|
+
|
|
121
|
+
def _build(self, is_async: bool) -> Any:
|
|
122
|
+
"""
|
|
123
|
+
Build a client, honouring ``safe`` when the connection cannot be made.
|
|
124
|
+
"""
|
|
125
|
+
client_cls = aioredis.Redis if is_async else redis.Redis
|
|
126
|
+
try:
|
|
127
|
+
return client_cls.from_url(self._url)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
if self._safe:
|
|
130
|
+
self.warning(f"Could not connect to Redis at {self._url}: {exc}")
|
|
131
|
+
return None
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
def _connect(self) -> None:
|
|
135
|
+
if RedisConnection._shared_clients.get(self._url) is None:
|
|
136
|
+
self.info(f"Creating shared Redis client for {self._url}")
|
|
137
|
+
RedisConnection._shared_clients[self._url] = self._build(is_async=False)
|
|
138
|
+
|
|
139
|
+
def _disconnect(self) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Deliberately a no-op: the sync client is a process-wide pool.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
async def _async_connect(self) -> None:
|
|
145
|
+
self._async_client = self._build(is_async=True)
|
|
146
|
+
|
|
147
|
+
async def _async_disconnect(self) -> None:
|
|
148
|
+
if self._async_client is not None:
|
|
149
|
+
try:
|
|
150
|
+
await self._async_client.aclose()
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
self.debug(f"Error closing async Redis client: {exc}")
|
|
153
|
+
self._async_client = None
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def close_shared_clients(cls) -> None:
|
|
157
|
+
"""
|
|
158
|
+
Close every shared sync client. For process shutdown and tests.
|
|
159
|
+
|
|
160
|
+
Logs through the module logger rather than Loggable, since there is no
|
|
161
|
+
instance here to carry one.
|
|
162
|
+
"""
|
|
163
|
+
for url, client in list(cls._shared_clients.items()):
|
|
164
|
+
try:
|
|
165
|
+
client.close()
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
logger.debug("Error closing Redis client for %s: %s", url, exc)
|
|
168
|
+
del cls._shared_clients[url]
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Sync API
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def get(self, key: str) -> Any:
|
|
175
|
+
return _decode(self.client.get(key))
|
|
176
|
+
|
|
177
|
+
def set(self, key: str, value: Any, **kwargs: Any) -> None:
|
|
178
|
+
self.client.set(key, _encode(value), **kwargs)
|
|
179
|
+
|
|
180
|
+
def delete(self, *keys: str) -> None:
|
|
181
|
+
self.client.delete(*keys)
|
|
182
|
+
|
|
183
|
+
def ping(self) -> bool:
|
|
184
|
+
return bool(self.client.ping())
|
|
185
|
+
|
|
186
|
+
def exists(self, key: str) -> bool:
|
|
187
|
+
return bool(self.client.exists(key))
|
|
188
|
+
|
|
189
|
+
def expire(self, key: str, seconds: int) -> None:
|
|
190
|
+
self.client.expire(key, seconds)
|
|
191
|
+
|
|
192
|
+
def ttl(self, key: str) -> int:
|
|
193
|
+
return self.client.ttl(key)
|
|
194
|
+
|
|
195
|
+
def incr(self, key: str, amount: int = 1) -> int:
|
|
196
|
+
return self.client.incr(key, amount)
|
|
197
|
+
|
|
198
|
+
def decr(self, key: str, amount: int = 1) -> int:
|
|
199
|
+
return self.client.decr(key, amount)
|
|
200
|
+
|
|
201
|
+
def keys(self, pattern: str = "*") -> list[Any]:
|
|
202
|
+
return self.client.keys(pattern)
|
|
203
|
+
|
|
204
|
+
def hget(self, key: str, field: str) -> Any:
|
|
205
|
+
return _decode(self.client.hget(key, field))
|
|
206
|
+
|
|
207
|
+
def hset(self, key: str, field: str, value: Any) -> None:
|
|
208
|
+
self.client.hset(key, field, _encode(value))
|
|
209
|
+
|
|
210
|
+
def hdel(self, key: str, *fields: str) -> None:
|
|
211
|
+
self.client.hdel(key, *fields)
|
|
212
|
+
|
|
213
|
+
def publish(self, channel: str, message: Any) -> int:
|
|
214
|
+
return self.client.publish(channel, _encode(message))
|
|
215
|
+
|
|
216
|
+
def pubsub(self) -> Any:
|
|
217
|
+
return self.client.pubsub()
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
# Async API
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
async def aget(self, key: str) -> Any:
|
|
224
|
+
return _decode(await self.async_client.get(key))
|
|
225
|
+
|
|
226
|
+
async def aset(self, key: str, value: Any, **kwargs: Any) -> None:
|
|
227
|
+
await self.async_client.set(key, _encode(value), **kwargs)
|
|
228
|
+
|
|
229
|
+
async def asetex(self, key: str, seconds: int, value: Any) -> None:
|
|
230
|
+
await self.async_client.setex(key, seconds, _encode(value))
|
|
231
|
+
|
|
232
|
+
async def adelete(self, *keys: str) -> None:
|
|
233
|
+
await self.async_client.delete(*keys)
|
|
234
|
+
|
|
235
|
+
async def akeys(self, pattern: str = "*") -> list[Any]:
|
|
236
|
+
return await self.async_client.keys(pattern)
|
|
237
|
+
|
|
238
|
+
async def apublish(self, channel: str, message: Any) -> int:
|
|
239
|
+
return await self.async_client.publish(channel, _encode(message))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-thread connection storage.
|
|
3
|
+
|
|
4
|
+
A thread keeps at most one open connection of each type, so a whole call tree
|
|
5
|
+
shares one session rather than opening a connection per function.
|
|
6
|
+
|
|
7
|
+
registry.get(SQLConnection) # this thread's connection, or None
|
|
8
|
+
|
|
9
|
+
registry.seed() # open one of every connection type
|
|
10
|
+
registry.teardown() # close them again
|
|
11
|
+
|
|
12
|
+
Connection classes are the keys. There is no list of known types to maintain:
|
|
13
|
+
``Connectable`` registers its subclasses as they are defined, so ``seed`` and
|
|
14
|
+
``teardown`` cover whatever the application has imported.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
|
|
19
|
+
from corekit.concurrency import ThreadLocalRegistry
|
|
20
|
+
from corekit.connections.connectable import Connectable
|
|
21
|
+
|
|
22
|
+
__all__ = ["ConnectionRegistry", "registry"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConnectionRegistry(ThreadLocalRegistry):
|
|
26
|
+
"""
|
|
27
|
+
Per-thread storage that opens and closes the connections it holds.
|
|
28
|
+
|
|
29
|
+
Adds lifecycle to ``ThreadLocalRegistry``: the base class stores whatever it
|
|
30
|
+
is given, while this one knows its values are ``Connectable`` and can
|
|
31
|
+
therefore open a full set for a thread and close them again afterwards.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def known_types() -> tuple[type[Connectable], ...]:
|
|
36
|
+
"""
|
|
37
|
+
Every concrete Connectable defined so far.
|
|
38
|
+
|
|
39
|
+
A class that has not been imported has no entry, which is correct:
|
|
40
|
+
nothing can be holding a connection of a type that does not yet exist.
|
|
41
|
+
"""
|
|
42
|
+
return tuple(Connectable.get_connection_types().values())
|
|
43
|
+
|
|
44
|
+
def seed(self, *connection_classes: type[Connectable], safe: bool = True) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Open and store a connection of each type, defaulting to all known types.
|
|
47
|
+
|
|
48
|
+
Failures are logged rather than raised unless ``safe`` is False, so one
|
|
49
|
+
unreachable service does not stop a worker thread from starting.
|
|
50
|
+
"""
|
|
51
|
+
for connection_cls in connection_classes or self.known_types():
|
|
52
|
+
if self.get(connection_cls) is not None:
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
connection = connection_cls()
|
|
56
|
+
connection.connect()
|
|
57
|
+
self.set(connection_cls, connection)
|
|
58
|
+
self.debug(f"Seeded {connection_cls.__name__} on thread {threading.current_thread().name}")
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
if not safe:
|
|
61
|
+
raise
|
|
62
|
+
self.warning(f"Could not seed {connection_cls.__name__}: {exc}")
|
|
63
|
+
|
|
64
|
+
def teardown(self, *connection_classes: type[Connectable]) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Disconnect and forget each connection, defaulting to all known types.
|
|
67
|
+
"""
|
|
68
|
+
for connection_cls in connection_classes or self.known_types():
|
|
69
|
+
connection = self.get(connection_cls)
|
|
70
|
+
if connection is None:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
connection.disconnect()
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
self.warning(f"Error disconnecting {connection_cls.__name__}: {exc}")
|
|
76
|
+
self.clear(connection_cls)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# One object; each thread sees its own connections.
|
|
80
|
+
registry: ConnectionRegistry = ConnectionRegistry()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL building blocks: a Connectable session, a query builder and a base table.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.connections.sql.connection import PaginatedResult, SQLConnection
|
|
6
|
+
from corekit.connections.sql.fields import JSONBField, PydanticJSON
|
|
7
|
+
from corekit.connections.sql.query import Query
|
|
8
|
+
from corekit.connections.sql.table import NamedTable
|
|
9
|
+
|
|
10
|
+
__all__ = ["JSONBField", "NamedTable", "PaginatedResult", "PydanticJSON", "Query", "SQLConnection"]
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL connections.
|
|
3
|
+
|
|
4
|
+
``SQLConnection`` is a ``Connectable`` over a SQLModel session. The database URL
|
|
5
|
+
is resolved in this order: the constructor argument, ``COREKIT_DATABASE_URL`` or
|
|
6
|
+
``database_url`` in configuration, and finally an on-disk SQLite file. Nothing is
|
|
7
|
+
read at import time, so importing this module never fails for want of a URL.
|
|
8
|
+
|
|
9
|
+
with SQLConnection.sqlite("app.db") as db:
|
|
10
|
+
db.insert(User(id="ada"))
|
|
11
|
+
user = db.fetch_one_by_id(User, "ada")
|
|
12
|
+
|
|
13
|
+
Engines are cached per URL for the life of the process; sessions are per
|
|
14
|
+
connection instance.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Generic, Iterator, NamedTuple, TypeVar
|
|
21
|
+
|
|
22
|
+
from sqlalchemy import Engine, func, text
|
|
23
|
+
from sqlmodel import Session, SQLModel, create_engine, select
|
|
24
|
+
|
|
25
|
+
from corekit.config import get_settings
|
|
26
|
+
from corekit.connections import Connectable
|
|
27
|
+
from corekit.connections.sql.query import Query
|
|
28
|
+
from corekit.connections.sql.table import NamedTable
|
|
29
|
+
|
|
30
|
+
__all__ = ["PaginatedResult", "SQLConnection"]
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
DEFAULT_SQLITE_DIR = Path.home() / ".corekit" / "databases"
|
|
37
|
+
POOL_SIZE = 10
|
|
38
|
+
MAX_OVERFLOW = 20
|
|
39
|
+
POOL_RECYCLE = 3600
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PaginatedResult(NamedTuple, Generic[T]):
|
|
43
|
+
"""
|
|
44
|
+
A page of rows, plus the total number of rows matching the query.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
rows: list[T]
|
|
48
|
+
total: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Engines are expensive and hold a connection pool, so one is kept per URL and
|
|
52
|
+
# shared by every connection using it. Sessions remain per-instance.
|
|
53
|
+
_engines: dict[str, Engine] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_engine(url: str) -> Engine:
|
|
57
|
+
"""
|
|
58
|
+
Return the shared engine for a URL, building it on first use.
|
|
59
|
+
"""
|
|
60
|
+
engine = _engines.get(url)
|
|
61
|
+
if engine is None:
|
|
62
|
+
logger.info(f"Creating SQLAlchemy engine for {url.split('://')[0]}")
|
|
63
|
+
if url.startswith("sqlite"):
|
|
64
|
+
# SQLite has no server-side pool, and its default single-thread
|
|
65
|
+
# check trips whenever a session is used off the creating thread.
|
|
66
|
+
engine = create_engine(url, connect_args={"check_same_thread": False})
|
|
67
|
+
else:
|
|
68
|
+
engine = create_engine(
|
|
69
|
+
url,
|
|
70
|
+
pool_size=POOL_SIZE,
|
|
71
|
+
max_overflow=MAX_OVERFLOW,
|
|
72
|
+
pool_recycle=POOL_RECYCLE,
|
|
73
|
+
pool_pre_ping=True,
|
|
74
|
+
)
|
|
75
|
+
_engines[url] = engine
|
|
76
|
+
return engine
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SQLConnection(Connectable):
|
|
80
|
+
"""
|
|
81
|
+
A session-scoped SQL connection.
|
|
82
|
+
|
|
83
|
+
Implements the Connectable lifecycle, so it works with ``with`` and
|
|
84
|
+
``async with`` and can be nested by connection-injecting decorators.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, connection_url: str | None = None, **kwargs: Any) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Build a connection. Without a URL, configuration is consulted, then a
|
|
90
|
+
local SQLite file is used.
|
|
91
|
+
"""
|
|
92
|
+
super().__init__(**kwargs)
|
|
93
|
+
self._connection_url = connection_url or self._default_url()
|
|
94
|
+
self._session: Session | None = None
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _default_url() -> str:
|
|
98
|
+
"""
|
|
99
|
+
Resolve a URL from configuration, falling back to local SQLite.
|
|
100
|
+
"""
|
|
101
|
+
configured = get_settings().database.url
|
|
102
|
+
if configured:
|
|
103
|
+
return configured
|
|
104
|
+
DEFAULT_SQLITE_DIR.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
return f"sqlite:///{DEFAULT_SQLITE_DIR / 'corekit.db'}"
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def sqlite(cls, filename: str, directory: Path | str | None = None, **kwargs: Any) -> "SQLConnection":
|
|
109
|
+
"""
|
|
110
|
+
Build a connection to a SQLite file, creating its directory if needed.
|
|
111
|
+
|
|
112
|
+
Zero configuration: no server, no environment variables.
|
|
113
|
+
"""
|
|
114
|
+
target_dir = Path(directory) if directory is not None else DEFAULT_SQLITE_DIR
|
|
115
|
+
target_dir = target_dir.expanduser()
|
|
116
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
return cls(f"sqlite:///{target_dir / filename}", **kwargs)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def in_memory(cls, shared_name: str | None = None, **kwargs: Any) -> "SQLConnection":
|
|
121
|
+
"""
|
|
122
|
+
Build a connection to an ephemeral in-memory database, for tests.
|
|
123
|
+
|
|
124
|
+
Each call gets its own database by default. Engines are cached per URL,
|
|
125
|
+
so a plain ``sqlite://`` would silently hand every caller the same one
|
|
126
|
+
and leak rows between tests. Pass ``shared_name`` when two connections
|
|
127
|
+
genuinely need to see the same in-memory data.
|
|
128
|
+
"""
|
|
129
|
+
name = shared_name or f"corekit_{uuid.uuid4().hex}"
|
|
130
|
+
return cls(f"sqlite:///file:{name}?mode=memory&cache=shared&uri=true", **kwargs)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def connection_url(self) -> str:
|
|
134
|
+
"""
|
|
135
|
+
The URL this connection was built with.
|
|
136
|
+
"""
|
|
137
|
+
return self._connection_url
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def session(self) -> Session:
|
|
141
|
+
"""
|
|
142
|
+
The active session, started on first use.
|
|
143
|
+
"""
|
|
144
|
+
self._start_session()
|
|
145
|
+
assert self._session is not None
|
|
146
|
+
return self._session
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def get_all_tables() -> Any:
|
|
150
|
+
"""
|
|
151
|
+
Every table registered in SQLModel's metadata.
|
|
152
|
+
"""
|
|
153
|
+
return SQLModel.metadata.tables.items()
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def is_connected(self) -> bool:
|
|
157
|
+
return self._session is not None
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def session_initialized(self) -> bool:
|
|
161
|
+
return self._session is not None
|
|
162
|
+
|
|
163
|
+
# =============
|
|
164
|
+
# Lifecycle
|
|
165
|
+
# =============
|
|
166
|
+
|
|
167
|
+
def _connect(self) -> None:
|
|
168
|
+
self._session = Session(_get_engine(self._connection_url))
|
|
169
|
+
|
|
170
|
+
def _disconnect(self) -> None:
|
|
171
|
+
if self._session is not None:
|
|
172
|
+
self._session.close()
|
|
173
|
+
self._session = None
|
|
174
|
+
|
|
175
|
+
async def _async_connect(self) -> None:
|
|
176
|
+
"""
|
|
177
|
+
SQLModel sessions are synchronous; this exists to satisfy Connectable.
|
|
178
|
+
"""
|
|
179
|
+
self._connect()
|
|
180
|
+
|
|
181
|
+
async def _async_disconnect(self) -> None:
|
|
182
|
+
"""
|
|
183
|
+
SQLModel sessions are synchronous; this exists to satisfy Connectable.
|
|
184
|
+
"""
|
|
185
|
+
self._disconnect()
|
|
186
|
+
|
|
187
|
+
def _start_session(self) -> None:
|
|
188
|
+
"""
|
|
189
|
+
Open a session if one is not already active.
|
|
190
|
+
"""
|
|
191
|
+
if not self.session_initialized:
|
|
192
|
+
self._connect()
|
|
193
|
+
|
|
194
|
+
def _exec(self, statement: Any) -> Any:
|
|
195
|
+
self._start_session()
|
|
196
|
+
return self.session.exec(statement)
|
|
197
|
+
|
|
198
|
+
# =============
|
|
199
|
+
# Schema
|
|
200
|
+
# =============
|
|
201
|
+
|
|
202
|
+
def initialize_database(self) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Create any tables present in SQLModel metadata but missing from the database.
|
|
205
|
+
"""
|
|
206
|
+
SQLModel.metadata.create_all(_get_engine(self._connection_url))
|
|
207
|
+
|
|
208
|
+
def exec_ddl(self, sql: str) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Run a raw DDL statement. Intended for migrations, not application code.
|
|
211
|
+
"""
|
|
212
|
+
self._start_session()
|
|
213
|
+
self.session.exec(text(sql))
|
|
214
|
+
self.session.commit()
|
|
215
|
+
|
|
216
|
+
# =============
|
|
217
|
+
# Writes
|
|
218
|
+
# =============
|
|
219
|
+
|
|
220
|
+
def insert(self, *items: Any) -> None:
|
|
221
|
+
"""
|
|
222
|
+
Insert new rows.
|
|
223
|
+
"""
|
|
224
|
+
self._start_session()
|
|
225
|
+
self.session.add_all(items)
|
|
226
|
+
self.session.commit()
|
|
227
|
+
|
|
228
|
+
def upsert(self, *items: Any) -> None:
|
|
229
|
+
"""
|
|
230
|
+
Insert or update rows. Slower than insert, but tolerates existing ids.
|
|
231
|
+
"""
|
|
232
|
+
self._start_session()
|
|
233
|
+
for item in items:
|
|
234
|
+
self.session.merge(item)
|
|
235
|
+
self.session.commit()
|
|
236
|
+
|
|
237
|
+
def delete(self, table: type[NamedTable], identifier: str) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Delete a row by id.
|
|
240
|
+
"""
|
|
241
|
+
item = self.fetch_one_by_id(table, identifier)
|
|
242
|
+
if item is None:
|
|
243
|
+
return
|
|
244
|
+
self.session.delete(item)
|
|
245
|
+
self.session.commit()
|
|
246
|
+
|
|
247
|
+
def rollback(self) -> None:
|
|
248
|
+
"""
|
|
249
|
+
Discard uncommitted changes.
|
|
250
|
+
"""
|
|
251
|
+
if self._session is not None:
|
|
252
|
+
self._session.rollback()
|
|
253
|
+
|
|
254
|
+
def refresh(self, item: Any) -> None:
|
|
255
|
+
"""
|
|
256
|
+
Reload an instance from the database.
|
|
257
|
+
"""
|
|
258
|
+
self.session.refresh(item)
|
|
259
|
+
|
|
260
|
+
# =============
|
|
261
|
+
# Reads
|
|
262
|
+
# =============
|
|
263
|
+
|
|
264
|
+
def exec(self, statement: Any) -> Any:
|
|
265
|
+
"""
|
|
266
|
+
Execute an arbitrary statement. Prefer a more specific method.
|
|
267
|
+
"""
|
|
268
|
+
return self._exec(statement)
|
|
269
|
+
|
|
270
|
+
def select(self, statement: Any) -> Any:
|
|
271
|
+
"""
|
|
272
|
+
Run a select statement.
|
|
273
|
+
"""
|
|
274
|
+
return self._exec(statement)
|
|
275
|
+
|
|
276
|
+
def first(self, statement: Any) -> Any:
|
|
277
|
+
"""
|
|
278
|
+
The first row of a select statement, or None.
|
|
279
|
+
"""
|
|
280
|
+
result = self.select(statement)
|
|
281
|
+
return None if result is None else result.first()
|
|
282
|
+
|
|
283
|
+
def fetch_all(self, table: type[NamedTable]) -> Iterator[NamedTable]:
|
|
284
|
+
"""
|
|
285
|
+
Every row in a table.
|
|
286
|
+
"""
|
|
287
|
+
return self._exec(select(table))
|
|
288
|
+
|
|
289
|
+
def fetch_one_by_id(self, table: type[NamedTable], identifier: str) -> NamedTable | None:
|
|
290
|
+
"""
|
|
291
|
+
A single row by id.
|
|
292
|
+
"""
|
|
293
|
+
return self.first(select(table).where(table.id == identifier))
|
|
294
|
+
|
|
295
|
+
def fetch_one_by_condition(self, table: type[NamedTable], key: Any, value: Any) -> NamedTable | None:
|
|
296
|
+
"""
|
|
297
|
+
The first row where a column equals a value.
|
|
298
|
+
"""
|
|
299
|
+
return self.first(select(table).where(key == value))
|
|
300
|
+
|
|
301
|
+
def exists(self, table: type[NamedTable], identifier: str) -> bool:
|
|
302
|
+
"""
|
|
303
|
+
Whether a row with this id exists.
|
|
304
|
+
"""
|
|
305
|
+
return self.fetch_one_by_id(table, identifier) is not None
|
|
306
|
+
|
|
307
|
+
def fetch_or_create_one(self, table: type[NamedTable], identifier: str) -> NamedTable:
|
|
308
|
+
"""
|
|
309
|
+
Fetch a row by id, inserting a default one if it is missing.
|
|
310
|
+
"""
|
|
311
|
+
item = self.fetch_one_by_id(table, identifier)
|
|
312
|
+
if item is None:
|
|
313
|
+
item = table.create_default(identifier)
|
|
314
|
+
self.insert(item)
|
|
315
|
+
return item
|
|
316
|
+
|
|
317
|
+
def fetch_many_by_query(self, query: Query) -> list[NamedTable]:
|
|
318
|
+
"""
|
|
319
|
+
Every row matching a Query.
|
|
320
|
+
"""
|
|
321
|
+
return list(self._exec(query.build()))
|
|
322
|
+
|
|
323
|
+
def fetch_one_by_query(self, query: Query) -> NamedTable | None:
|
|
324
|
+
"""
|
|
325
|
+
The first row matching a Query.
|
|
326
|
+
"""
|
|
327
|
+
return self.first(query.build())
|
|
328
|
+
|
|
329
|
+
def count(self, table: type[NamedTable]) -> int:
|
|
330
|
+
"""
|
|
331
|
+
The number of rows in a table.
|
|
332
|
+
"""
|
|
333
|
+
self._start_session()
|
|
334
|
+
return self.session.exec(select(func.count()).select_from(table)).one()
|
|
335
|
+
|
|
336
|
+
def paginate(self, table: type[NamedTable], offset: int = 0, limit: int = 50) -> PaginatedResult:
|
|
337
|
+
"""
|
|
338
|
+
A page of rows, together with the total row count.
|
|
339
|
+
"""
|
|
340
|
+
total = self.count(table)
|
|
341
|
+
rows = list(self._exec(select(table).offset(offset).limit(limit)))
|
|
342
|
+
return PaginatedResult(rows=rows, total=total)
|