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.
Files changed (125) hide show
  1. corekit/__init__.py +0 -0
  2. corekit/api/__init__.py +9 -0
  3. corekit/api/handler.py +76 -0
  4. corekit/api/responses.py +40 -0
  5. corekit/api/routers.py +115 -0
  6. corekit/concurrency/__init__.py +9 -0
  7. corekit/concurrency/decorators.py +72 -0
  8. corekit/concurrency/thread_local.py +99 -0
  9. corekit/concurrency/worker.py +65 -0
  10. corekit/config/__init__.py +47 -0
  11. corekit/config/loader.py +153 -0
  12. corekit/config/settings.py +161 -0
  13. corekit/config/sources.py +125 -0
  14. corekit/connections/__init__.py +31 -0
  15. corekit/connections/connectable.py +212 -0
  16. corekit/connections/decorators.py +92 -0
  17. corekit/connections/redis/__init__.py +7 -0
  18. corekit/connections/redis/connection.py +239 -0
  19. corekit/connections/registry.py +80 -0
  20. corekit/connections/sql/__init__.py +10 -0
  21. corekit/connections/sql/connection.py +342 -0
  22. corekit/connections/sql/fields/__init__.py +7 -0
  23. corekit/connections/sql/fields/jsonb.py +67 -0
  24. corekit/connections/sql/migration/__init__.py +57 -0
  25. corekit/connections/sql/migration/base.py +40 -0
  26. corekit/connections/sql/migration/operations.py +416 -0
  27. corekit/connections/sql/migration/registry.py +166 -0
  28. corekit/connections/sql/migration/table.py +27 -0
  29. corekit/connections/sql/query.py +68 -0
  30. corekit/connections/sql/table.py +96 -0
  31. corekit/constants.py +45 -0
  32. corekit/crypto/__init__.py +1 -0
  33. corekit/crypto/constants.py +7 -0
  34. corekit/crypto/enum.py +11 -0
  35. corekit/crypto/hasher.py +89 -0
  36. corekit/data/__init__.py +81 -0
  37. corekit/data/dataset.py +340 -0
  38. corekit/data/expressions/__init__.py +46 -0
  39. corekit/data/expressions/comparison.py +252 -0
  40. corekit/data/expressions/expression.py +98 -0
  41. corekit/data/record.py +147 -0
  42. corekit/data/stats.py +157 -0
  43. corekit/decorators/__init__.py +2 -0
  44. corekit/decorators/exception_handling.py +43 -0
  45. corekit/decorators/warnings.py +35 -0
  46. corekit/docker/__init__.py +7 -0
  47. corekit/docker/watchdog.py +222 -0
  48. corekit/etl/__init__.py +44 -0
  49. corekit/etl/connection.py +44 -0
  50. corekit/etl/extract/__init__.py +0 -0
  51. corekit/etl/extract/extractor.py +48 -0
  52. corekit/etl/extract/schemas.py +18 -0
  53. corekit/etl/load/__init__.py +0 -0
  54. corekit/etl/load/loader.py +53 -0
  55. corekit/etl/load/schemas.py +33 -0
  56. corekit/etl/orchestrator.py +201 -0
  57. corekit/etl/schemas.py +22 -0
  58. corekit/etl/transform/__init__.py +0 -0
  59. corekit/etl/transform/schemas.py +15 -0
  60. corekit/etl/transform/transformer.py +28 -0
  61. corekit/events/__init__.py +38 -0
  62. corekit/events/enum.py +58 -0
  63. corekit/events/frames.py +51 -0
  64. corekit/events/models.py +23 -0
  65. corekit/events/publisher.py +75 -0
  66. corekit/events/reader.py +132 -0
  67. corekit/events/sse.py +109 -0
  68. corekit/events/websocket.py +97 -0
  69. corekit/exceptions/__init__.py +0 -0
  70. corekit/exceptions/base.py +45 -0
  71. corekit/exceptions/custom/__init__.py +0 -0
  72. corekit/exceptions/http/__init__.py +0 -0
  73. corekit/exceptions/http/exceptions.py +37 -0
  74. corekit/exceptions/types.py +17 -0
  75. corekit/files/__init__.py +25 -0
  76. corekit/files/base.py +117 -0
  77. corekit/files/enum.py +30 -0
  78. corekit/files/json.py +12 -0
  79. corekit/files/pickle.py +12 -0
  80. corekit/files/toml.py +43 -0
  81. corekit/http/__init__.py +0 -0
  82. corekit/http/client.py +176 -0
  83. corekit/http/exponential_backoff.py +100 -0
  84. corekit/http/response.py +12 -0
  85. corekit/log_monitor/__init__.py +23 -0
  86. corekit/log_monitor/constants.py +8 -0
  87. corekit/log_monitor/models.py +150 -0
  88. corekit/log_monitor/service.py +418 -0
  89. corekit/notifications/__init__.py +8 -0
  90. corekit/notifications/base.py +51 -0
  91. corekit/notifications/models.py +34 -0
  92. corekit/observability/__init__.py +21 -0
  93. corekit/observability/benchmarkable.py +12 -0
  94. corekit/observability/loggable.py +29 -0
  95. corekit/observability/timing/__init__.py +0 -0
  96. corekit/observability/timing/constants.py +1 -0
  97. corekit/observability/timing/split.py +20 -0
  98. corekit/observability/timing/timer.py +30 -0
  99. corekit/py.typed +0 -0
  100. corekit/registry/__init__.py +12 -0
  101. corekit/registry/registry.py +134 -0
  102. corekit/schemas/__init__.py +0 -0
  103. corekit/schemas/dataclasses/__init__.py +0 -0
  104. corekit/schemas/enum.py +49 -0
  105. corekit/schemas/models/__init__.py +0 -0
  106. corekit/schemas/models/arbitrary.py +11 -0
  107. corekit/schemas/models/date_models.py +18 -0
  108. corekit/schemas/pydantic/__init__.py +0 -0
  109. corekit/schemas/pydantic/fields.py +35 -0
  110. corekit/schemas/types.py +40 -0
  111. corekit/serialization/__init__.py +0 -0
  112. corekit/serialization/enum.py +21 -0
  113. corekit/serialization/serializable.py +42 -0
  114. corekit/serialization/serializer.py +179 -0
  115. corekit/utils/__init__.py +5 -0
  116. corekit/utils/ids.py +5 -0
  117. corekit/utils/raise_exc.py +8 -0
  118. corekit/utils/time.py +21 -0
  119. corekit/utils/validators.py +15 -0
  120. corekit/utils/void.py +8 -0
  121. python_corekit-0.1.0.dist-info/METADATA +417 -0
  122. python_corekit-0.1.0.dist-info/RECORD +125 -0
  123. python_corekit-0.1.0.dist-info/WHEEL +5 -0
  124. python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
  125. 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
@@ -0,0 +1,7 @@
1
+ """
2
+ Redis connections.
3
+ """
4
+
5
+ from corekit.connections.redis.connection import RedisConnection, RedisNotConnectedError
6
+
7
+ __all__ = ["RedisConnection", "RedisNotConnectedError"]