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,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The settings themselves.
|
|
3
|
+
|
|
4
|
+
Grouped by concern rather than held in one flat namespace, so
|
|
5
|
+
``settings.concurrency.max_threads`` says where a value belongs and a new
|
|
6
|
+
section does not widen an already-wide class.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, model_validator
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ConcurrencySettings",
|
|
17
|
+
"CryptoSettings",
|
|
18
|
+
"DatabaseSettings",
|
|
19
|
+
"CorekitSettings",
|
|
20
|
+
"RedisSettings",
|
|
21
|
+
"SerializationSettings",
|
|
22
|
+
"StandardsSettings",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
ENV_PREFIX = "COREKIT_"
|
|
26
|
+
|
|
27
|
+
# A ceiling for max_threads. Thread pools are cheap to ask for and expensive to
|
|
28
|
+
# get wrong; this stops a typo in a config file from trying to start thousands.
|
|
29
|
+
ABSOLUTE_THREAD_LIMIT = 256
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StandardsSettings(BaseModel):
|
|
33
|
+
"""
|
|
34
|
+
Enforcement of house rules.
|
|
35
|
+
|
|
36
|
+
Off by default. These are conventions, not library invariants, and crashing
|
|
37
|
+
a consumer's application over a missing docstring would be hostile.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
require_handler_docstrings: bool = False
|
|
41
|
+
strict_mode: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConcurrencySettings(BaseModel):
|
|
45
|
+
"""
|
|
46
|
+
Limits on how many threads corekit will start on a caller's behalf.
|
|
47
|
+
|
|
48
|
+
``default_threads`` is what a helper uses when the caller does not say, and
|
|
49
|
+
``max_threads`` is the ceiling it will not exceed however it is asked. The
|
|
50
|
+
ceiling exists because a thread count is easy to get wrong by an order of
|
|
51
|
+
magnitude in a config file, and the failure is a hung machine rather than an
|
|
52
|
+
error message.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
default_threads: int = Field(default=4, ge=1, le=ABSOLUTE_THREAD_LIMIT)
|
|
56
|
+
max_threads: int = Field(default=32, ge=1, le=ABSOLUTE_THREAD_LIMIT)
|
|
57
|
+
|
|
58
|
+
@model_validator(mode="after")
|
|
59
|
+
def _default_within_ceiling(self) -> "ConcurrencySettings":
|
|
60
|
+
"""
|
|
61
|
+
Keep the default at or below the ceiling.
|
|
62
|
+
|
|
63
|
+
Configuring a default above the maximum is a contradiction, and silently
|
|
64
|
+
honouring one of the two would hide the mistake.
|
|
65
|
+
"""
|
|
66
|
+
if self.default_threads > self.max_threads:
|
|
67
|
+
raise ValueError(f"default_threads ({self.default_threads}) exceeds max_threads ({self.max_threads})")
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def resolve(self, requested: int | None = None) -> int:
|
|
71
|
+
"""
|
|
72
|
+
The thread count to actually use.
|
|
73
|
+
|
|
74
|
+
Falls back to the default, and never returns more than the ceiling, so a
|
|
75
|
+
caller asking for a thousand threads gets the maximum rather than a
|
|
76
|
+
thousand threads or an exception.
|
|
77
|
+
"""
|
|
78
|
+
return min(self.default_threads if requested is None else max(1, requested), self.max_threads)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def cpu_default() -> int:
|
|
82
|
+
"""
|
|
83
|
+
A reasonable thread count for this machine, for callers that want one.
|
|
84
|
+
"""
|
|
85
|
+
return min(32, (os.cpu_count() or 1) * 5)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CryptoSettings(BaseModel):
|
|
89
|
+
"""
|
|
90
|
+
Hashing configuration.
|
|
91
|
+
|
|
92
|
+
``salt`` has no default on purpose: the value that used to be hardcoded is
|
|
93
|
+
in git history and must be treated as compromised, so hashing raises until
|
|
94
|
+
one is supplied.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
salt: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DatabaseSettings(BaseModel):
|
|
101
|
+
"""
|
|
102
|
+
Database connection details. ``None`` means the caller supplies a URL.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
url: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RedisSettings(BaseModel):
|
|
109
|
+
"""
|
|
110
|
+
Redis connection details.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
url: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SerializationSettings(BaseModel):
|
|
117
|
+
"""
|
|
118
|
+
Payload authentication.
|
|
119
|
+
|
|
120
|
+
``key`` gates pickle and dill, both of which execute code while loading.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
key: str | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CorekitSettings(BaseSettings):
|
|
127
|
+
"""
|
|
128
|
+
Runtime configuration for corekit.
|
|
129
|
+
|
|
130
|
+
Every field has a safe default, so an application that configures nothing
|
|
131
|
+
still gets working behaviour.
|
|
132
|
+
|
|
133
|
+
Nested sections are addressed with a double underscore in the environment::
|
|
134
|
+
|
|
135
|
+
COREKIT_CONCURRENCY__MAX_THREADS=16
|
|
136
|
+
|
|
137
|
+
and as tables in a config file::
|
|
138
|
+
|
|
139
|
+
[concurrency]
|
|
140
|
+
max_threads = 16
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
model_config = SettingsConfigDict(
|
|
144
|
+
env_prefix=ENV_PREFIX,
|
|
145
|
+
env_nested_delimiter="__",
|
|
146
|
+
env_ignore_empty=True,
|
|
147
|
+
extra="ignore",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
standards: StandardsSettings = Field(default_factory=StandardsSettings)
|
|
151
|
+
concurrency: ConcurrencySettings = Field(default_factory=ConcurrencySettings)
|
|
152
|
+
crypto: CryptoSettings = Field(default_factory=CryptoSettings)
|
|
153
|
+
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
|
154
|
+
redis: RedisSettings = Field(default_factory=RedisSettings)
|
|
155
|
+
serialization: SerializationSettings = Field(default_factory=SerializationSettings)
|
|
156
|
+
|
|
157
|
+
def merged_with(self, **overrides: Any) -> "CorekitSettings":
|
|
158
|
+
"""
|
|
159
|
+
A copy with the given sections replaced. Useful in tests.
|
|
160
|
+
"""
|
|
161
|
+
return self.model_copy(update=overrides)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Where configuration values come from.
|
|
3
|
+
|
|
4
|
+
Each source knows how to produce a mapping and nothing else, so adding one -- a
|
|
5
|
+
different format, a secrets store -- means adding a class rather than editing a
|
|
6
|
+
function that already handles three cases.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, ClassVar
|
|
13
|
+
|
|
14
|
+
from corekit.files import TomlFileManager
|
|
15
|
+
from corekit.observability import Loggable
|
|
16
|
+
|
|
17
|
+
__all__ = ["ConfigFileSource", "ConfigSource", "EnvironmentSource", "PyprojectSource"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigSource(Loggable, ABC):
|
|
21
|
+
"""
|
|
22
|
+
A place configuration values can be read from.
|
|
23
|
+
|
|
24
|
+
Sources never raise: an unreadable or malformed source contributes nothing
|
|
25
|
+
and says so in the log. Configuration is optional, and a library that
|
|
26
|
+
refuses to import because a stray file has a typo in it is worse than one
|
|
27
|
+
that falls back to its defaults.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def load(self) -> dict[str, Any]:
|
|
32
|
+
"""
|
|
33
|
+
Return this source's values, or an empty mapping if it has none.
|
|
34
|
+
"""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EnvironmentSource(ConfigSource):
|
|
39
|
+
"""
|
|
40
|
+
Values from prefixed environment variables.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
#: Separates a section from a field: COREKIT_CONCURRENCY__MAX_THREADS.
|
|
44
|
+
NESTED_DELIMITER: ClassVar[str] = "__"
|
|
45
|
+
|
|
46
|
+
def __init__(self, prefix: str) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.prefix = prefix
|
|
49
|
+
|
|
50
|
+
def load(self) -> dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Collect prefixed variables, treating empty values as unset.
|
|
53
|
+
|
|
54
|
+
Container runtimes routinely pass ``FOO=`` for a variable that was never
|
|
55
|
+
set, which would otherwise override a default with an empty string.
|
|
56
|
+
"""
|
|
57
|
+
values: dict[str, Any] = {}
|
|
58
|
+
for key, value in os.environ.items():
|
|
59
|
+
if not key.startswith(self.prefix) or value == "":
|
|
60
|
+
continue
|
|
61
|
+
name = key[len(self.prefix) :].lower()
|
|
62
|
+
section, delimiter, field = name.partition(self.NESTED_DELIMITER)
|
|
63
|
+
if delimiter:
|
|
64
|
+
values.setdefault(section, {})[field] = value
|
|
65
|
+
else:
|
|
66
|
+
values[name] = value
|
|
67
|
+
return values
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConfigFileSource(ConfigSource):
|
|
71
|
+
"""
|
|
72
|
+
Values from the top level of a TOML file.
|
|
73
|
+
|
|
74
|
+
Reads through ``TomlFileManager`` rather than calling ``tomllib`` directly,
|
|
75
|
+
so file handling stays in one place and this class is only responsible for
|
|
76
|
+
deciding what part of the parsed document to keep.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, path: Path) -> None:
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.path = path
|
|
82
|
+
|
|
83
|
+
def exists(self) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
Whether this file is present and readable.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
return self.path.is_file()
|
|
89
|
+
except OSError:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def _read(self) -> dict[str, Any]:
|
|
93
|
+
"""
|
|
94
|
+
Parse the file, returning an empty mapping if it cannot be read.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
with TomlFileManager(str(self.path)) as file:
|
|
98
|
+
parsed = file.read()
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
self.warning(f"Ignoring unreadable config file {self.path}: {exc}")
|
|
101
|
+
return {}
|
|
102
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
103
|
+
|
|
104
|
+
def load(self) -> dict[str, Any]:
|
|
105
|
+
return self._read()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class PyprojectSource(ConfigFileSource):
|
|
109
|
+
"""
|
|
110
|
+
Values from a ``pyproject.toml``'s ``[tool.corekit]`` table.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
#: The table this source reads, outermost key first.
|
|
114
|
+
TABLE_PATH: ClassVar[list[str]] = ["tool", "corekit"]
|
|
115
|
+
|
|
116
|
+
def load(self) -> dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Return the tool table, or nothing if the file does not declare one.
|
|
119
|
+
"""
|
|
120
|
+
section: Any = self._read()
|
|
121
|
+
for key in self.TABLE_PATH:
|
|
122
|
+
if not isinstance(section, dict):
|
|
123
|
+
return {}
|
|
124
|
+
section = section.get(key, {})
|
|
125
|
+
return section if isinstance(section, dict) else {}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connections: the abstraction, the concrete backends, and injection.
|
|
3
|
+
|
|
4
|
+
``Connectable`` defines the lifecycle -- connect, disconnect, and both context
|
|
5
|
+
manager protocols -- and ``sql`` and ``redis`` implement it. ``@connect`` opens
|
|
6
|
+
one and hands it to a function, reusing whatever an outer call already opened.
|
|
7
|
+
|
|
8
|
+
from corekit.connections import connect
|
|
9
|
+
from corekit.connections.sql import SQLConnection
|
|
10
|
+
|
|
11
|
+
@connect(SQLConnection)
|
|
12
|
+
def get_user(conn, user_id: str) -> User:
|
|
13
|
+
return conn.fetch_one_by_id(User, user_id)
|
|
14
|
+
|
|
15
|
+
The backends live here rather than at the top level because they are
|
|
16
|
+
implementations of the abstraction directly above them, and there is no useful
|
|
17
|
+
way to think about one without the other.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from corekit.connections.connectable import Connectable, ConnectableType, ConnectionPreference
|
|
21
|
+
from corekit.connections.decorators import connect
|
|
22
|
+
from corekit.connections.registry import ConnectionRegistry, registry
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Connectable",
|
|
26
|
+
"ConnectableType",
|
|
27
|
+
"ConnectionPreference",
|
|
28
|
+
"ConnectionRegistry",
|
|
29
|
+
"connect",
|
|
30
|
+
"registry",
|
|
31
|
+
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A uniform way to connect to anything.
|
|
3
|
+
|
|
4
|
+
``Connectable`` is a template-method base: a subclass implements four private
|
|
5
|
+
hooks plus ``is_connected``, and inherits idempotent public ``connect`` /
|
|
6
|
+
``disconnect``, both context manager protocols, and logging.
|
|
7
|
+
|
|
8
|
+
class MyDatabase(Connectable):
|
|
9
|
+
...
|
|
10
|
+
|
|
11
|
+
with MyDatabase() as db:
|
|
12
|
+
rows = db.query(...)
|
|
13
|
+
|
|
14
|
+
async with MyDatabase() as db:
|
|
15
|
+
rows = await db.async_query(...)
|
|
16
|
+
|
|
17
|
+
Subclasses register themselves by name, so tooling can resolve a connection
|
|
18
|
+
class without importing it directly.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from corekit.observability.loggable import Loggable
|
|
25
|
+
from corekit.registry import SmartRegistry
|
|
26
|
+
from corekit.schemas.enum import StringEnum
|
|
27
|
+
|
|
28
|
+
__all__ = ["Connectable", "ConnectableType", "ConnectionPreference"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConnectionPreference(StringEnum):
|
|
32
|
+
"""
|
|
33
|
+
Whether a connection should prefer its sync or async path when both exist.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
ASYNC = "async"
|
|
37
|
+
SYNC = "sync"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Connectable(Loggable, ABC):
|
|
41
|
+
"""
|
|
42
|
+
Base class for anything with a connection lifecycle.
|
|
43
|
+
|
|
44
|
+
Implement ``is_connected``, ``_connect``, ``_disconnect``,
|
|
45
|
+
``_async_connect`` and ``_async_disconnect``. The public methods are
|
|
46
|
+
provided and should not be overridden: they make connecting idempotent and
|
|
47
|
+
keep logging consistent across every connection type.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
__registry__: SmartRegistry = SmartRegistry()
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*args: Any,
|
|
55
|
+
connection_preference: ConnectionPreference = ConnectionPreference.ASYNC,
|
|
56
|
+
read_only: bool = False,
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__()
|
|
60
|
+
self._read_only = read_only
|
|
61
|
+
self._connection_preference = connection_preference
|
|
62
|
+
|
|
63
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Register every concrete subclass under its class name.
|
|
66
|
+
|
|
67
|
+
This is what lets a connection registry stay open. Enumerating known
|
|
68
|
+
connection types in a fixed list means the enumerating module has to
|
|
69
|
+
import each concrete class, which inverts the dependency and limits
|
|
70
|
+
callers to the types that list happens to mention.
|
|
71
|
+
"""
|
|
72
|
+
super().__init_subclass__(**kwargs)
|
|
73
|
+
if not getattr(cls, "__abstractmethods__", None):
|
|
74
|
+
Connectable.__registry__[cls.__name__] = cls
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def get_connection_types(cls) -> SmartRegistry:
|
|
78
|
+
"""
|
|
79
|
+
Return the registry of concrete Connectable subclasses.
|
|
80
|
+
"""
|
|
81
|
+
return cls.__registry__
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def get_connection_by_name(cls, name: str) -> type["Connectable"] | None:
|
|
85
|
+
"""
|
|
86
|
+
Look up a connection class by name, using the registry's normalization.
|
|
87
|
+
"""
|
|
88
|
+
return cls.__registry__.get(name)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def read_only(self) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Whether this connection was opened for reading only.
|
|
94
|
+
"""
|
|
95
|
+
return self._read_only
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def connection_preference(self) -> ConnectionPreference:
|
|
99
|
+
"""
|
|
100
|
+
Whether this connection prefers its sync or async path.
|
|
101
|
+
"""
|
|
102
|
+
return self._connection_preference
|
|
103
|
+
|
|
104
|
+
def __repr__(self) -> str:
|
|
105
|
+
return f"{self.__class__.__name__}(connected={self.is_connected})"
|
|
106
|
+
|
|
107
|
+
def __str__(self) -> str:
|
|
108
|
+
return self.__repr__()
|
|
109
|
+
|
|
110
|
+
def __bool__(self) -> bool:
|
|
111
|
+
return self.is_connected
|
|
112
|
+
|
|
113
|
+
# ========================================
|
|
114
|
+
# = Context Managers =
|
|
115
|
+
# ========================================
|
|
116
|
+
def __enter__(self) -> "Connectable":
|
|
117
|
+
self.connect()
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
121
|
+
self.disconnect()
|
|
122
|
+
|
|
123
|
+
async def __aenter__(self) -> "Connectable":
|
|
124
|
+
await self.async_connect()
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
128
|
+
await self.async_disconnect()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
@abstractmethod
|
|
132
|
+
def is_connected(self) -> bool:
|
|
133
|
+
"""
|
|
134
|
+
Whether the connection is currently established.
|
|
135
|
+
"""
|
|
136
|
+
raise NotImplementedError
|
|
137
|
+
|
|
138
|
+
# ========================================
|
|
139
|
+
# = Private Connection Methods =
|
|
140
|
+
# ========================================
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def _disconnect(self) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Tear the connection down. Must be safe to call when not connected.
|
|
145
|
+
"""
|
|
146
|
+
raise NotImplementedError
|
|
147
|
+
|
|
148
|
+
@abstractmethod
|
|
149
|
+
def _connect(self) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Establish the connection.
|
|
152
|
+
"""
|
|
153
|
+
raise NotImplementedError
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
async def _async_disconnect(self) -> None:
|
|
157
|
+
"""
|
|
158
|
+
Tear the connection down asynchronously. Must be safe when not connected.
|
|
159
|
+
"""
|
|
160
|
+
raise NotImplementedError
|
|
161
|
+
|
|
162
|
+
@abstractmethod
|
|
163
|
+
async def _async_connect(self) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Establish the connection asynchronously.
|
|
166
|
+
"""
|
|
167
|
+
raise NotImplementedError
|
|
168
|
+
|
|
169
|
+
# ========================================
|
|
170
|
+
# = Public Connection Methods =
|
|
171
|
+
# ========================================
|
|
172
|
+
# Provided for the caller; subclasses implement the private hooks above so
|
|
173
|
+
# that lifecycle handling stays identical across every connection type.
|
|
174
|
+
def connect(self, force_reconnect: bool = False) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Connect if not already connected. Pass ``force_reconnect`` to rebuild
|
|
177
|
+
an existing connection.
|
|
178
|
+
"""
|
|
179
|
+
if self.is_connected and not force_reconnect:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
self.debug(f"Establishing {self.__class__.__name__} connection")
|
|
183
|
+
self._disconnect()
|
|
184
|
+
self._connect()
|
|
185
|
+
|
|
186
|
+
def disconnect(self) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Close the connection.
|
|
189
|
+
"""
|
|
190
|
+
self.debug(f"Terminating {self.__class__.__name__} connection")
|
|
191
|
+
self._disconnect()
|
|
192
|
+
|
|
193
|
+
async def async_connect(self, force_reconnect: bool = False) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Connect asynchronously if not already connected.
|
|
196
|
+
"""
|
|
197
|
+
if self.is_connected and not force_reconnect:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
self.debug(f"Establishing async {self.__class__.__name__} connection")
|
|
201
|
+
await self._async_disconnect()
|
|
202
|
+
await self._async_connect()
|
|
203
|
+
|
|
204
|
+
async def async_disconnect(self) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Close the connection asynchronously.
|
|
207
|
+
"""
|
|
208
|
+
self.debug(f"Terminating async {self.__class__.__name__} connection")
|
|
209
|
+
await self._async_disconnect()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
ConnectableType = type[Connectable]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connection injection.
|
|
3
|
+
|
|
4
|
+
``@connect`` opens a connection, hands it to the function as ``conn``, and
|
|
5
|
+
closes it afterwards. Nested calls reuse the connection the outer one opened,
|
|
6
|
+
so a whole call tree shares a single session::
|
|
7
|
+
|
|
8
|
+
@connect(SQLConnection)
|
|
9
|
+
def get_user(conn, user_id: str) -> User:
|
|
10
|
+
return conn.fetch_one_by_id(User, user_id)
|
|
11
|
+
|
|
12
|
+
@connect(SQLConnection)
|
|
13
|
+
def get_team(conn, team_id: str) -> Team:
|
|
14
|
+
# get_user reuses this connection rather than opening another
|
|
15
|
+
return Team(members=[get_user(uid) for uid in ...])
|
|
16
|
+
|
|
17
|
+
``conn`` is stripped from the wrapped function's signature, so frameworks that
|
|
18
|
+
introspect signatures -- FastAPI's dependency injection in particular -- never
|
|
19
|
+
see it. That is what lets the same decorator work on HTTP routes, background
|
|
20
|
+
jobs, websocket handlers and plain functions alike.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import functools
|
|
24
|
+
import inspect
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from corekit.connections.connectable import ConnectableType
|
|
28
|
+
from corekit.connections.registry import registry
|
|
29
|
+
|
|
30
|
+
__all__ = ["connect"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def connect(connector: ConnectableType, *connector_args: Any, **connector_kwargs: Any) -> Any:
|
|
34
|
+
"""
|
|
35
|
+
Inject a managed connection as the ``conn`` parameter.
|
|
36
|
+
|
|
37
|
+
:param connector: the Connectable subclass to open.
|
|
38
|
+
:param connector_args: positional arguments for its constructor.
|
|
39
|
+
:param connector_kwargs: keyword arguments for its constructor.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def decorator(func: Any) -> Any:
|
|
43
|
+
params = list(inspect.signature(func).parameters)
|
|
44
|
+
conn_index = 1 if params and params[0] == "self" else 0
|
|
45
|
+
|
|
46
|
+
# inspect.unwrap sees through other decorators, so an async function
|
|
47
|
+
# wrapped in something else is still detected as async.
|
|
48
|
+
unwrapped = inspect.unwrap(func)
|
|
49
|
+
is_async = inspect.iscoroutinefunction(unwrapped) or inspect.isasyncgenfunction(unwrapped)
|
|
50
|
+
|
|
51
|
+
def _call(connection: Any, args: tuple, kwargs: dict) -> Any:
|
|
52
|
+
injected = args[:conn_index] + (connection,) + args[conn_index:]
|
|
53
|
+
return func(*injected, **kwargs)
|
|
54
|
+
|
|
55
|
+
@functools.wraps(func)
|
|
56
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
57
|
+
existing = registry.get(connector)
|
|
58
|
+
if existing is not None:
|
|
59
|
+
result = _call(existing, args, kwargs)
|
|
60
|
+
return await result if inspect.isawaitable(result) else result
|
|
61
|
+
|
|
62
|
+
with connector(*connector_args, **connector_kwargs) as connection:
|
|
63
|
+
registry.set(connector, connection)
|
|
64
|
+
try:
|
|
65
|
+
result = _call(connection, args, kwargs)
|
|
66
|
+
return await result if inspect.isawaitable(result) else result
|
|
67
|
+
finally:
|
|
68
|
+
registry.clear(connector)
|
|
69
|
+
|
|
70
|
+
@functools.wraps(func)
|
|
71
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
72
|
+
existing = registry.get(connector)
|
|
73
|
+
if existing is not None:
|
|
74
|
+
return _call(existing, args, kwargs)
|
|
75
|
+
|
|
76
|
+
with connector(*connector_args, **connector_kwargs) as connection:
|
|
77
|
+
registry.set(connector, connection)
|
|
78
|
+
try:
|
|
79
|
+
return _call(connection, args, kwargs)
|
|
80
|
+
finally:
|
|
81
|
+
registry.clear(connector)
|
|
82
|
+
|
|
83
|
+
wrapper = async_wrapper if is_async else sync_wrapper
|
|
84
|
+
|
|
85
|
+
# Hide `conn` from anything that introspects the signature.
|
|
86
|
+
original = inspect.signature(func)
|
|
87
|
+
wrapper.__signature__ = original.replace(
|
|
88
|
+
parameters=[p for name, p in original.parameters.items() if name != "conn"]
|
|
89
|
+
)
|
|
90
|
+
return wrapper
|
|
91
|
+
|
|
92
|
+
return decorator
|