python-corekit 0.1.1__py3-none-any.whl → 0.3.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/api/__init__.py +18 -3
- corekit/api/application.py +275 -0
- corekit/api/lifespan.py +233 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +74 -65
- corekit/config/loader.py +42 -5
- corekit/config/settings.py +11 -1
- corekit/connections/__init__.py +7 -1
- corekit/connections/connectable.py +45 -4
- corekit/connections/redis/connection.py +53 -10
- corekit/connections/sql/__init__.py +33 -4
- corekit/connections/sql/connection.py +56 -3
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +9 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +135 -44
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +111 -0
- corekit/connections/sql/operations/statements.py +170 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +33 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/__init__.py +8 -0
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +142 -123
- corekit/data/expressions/expression.py +71 -98
- corekit/data/expressions/operator.py +39 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +162 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +38 -9
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +46 -44
- corekit/etl/extract/extractor.py +6 -13
- corekit/etl/orchestrator.py +19 -2
- corekit/etl/schemas.py +2 -2
- corekit/etl/transform/transformer.py +4 -1
- corekit/events/publisher.py +1 -1
- corekit/events/reader.py +26 -21
- corekit/events/sse.py +4 -1
- corekit/events/websocket.py +27 -13
- corekit/exceptions/__init__.py +33 -0
- corekit/exceptions/base.py +139 -10
- corekit/exceptions/enum.py +17 -0
- corekit/exceptions/types.py +6 -6
- corekit/files/__init__.py +2 -4
- corekit/files/base.py +15 -2
- corekit/files/enum.py +0 -5
- corekit/files/json.py +16 -2
- corekit/http/__init__.py +51 -0
- corekit/http/api.py +24 -0
- corekit/http/client.py +100 -73
- corekit/http/exceptions.py +140 -0
- corekit/http/response.py +50 -1
- corekit/http/status.py +89 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +80 -0
- corekit/jobs/task.py +173 -0
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -3
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +188 -0
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +12 -7
- corekit/registry/ordered.py +86 -0
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +70 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +45 -40
- corekit/serialization/__init__.py +24 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +10 -3
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +124 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +112 -0
- corekit/utils/raise_exc.py +8 -8
- corekit/utils/text.py +56 -0
- corekit/utils/time.py +74 -21
- corekit/utils/validators.py +15 -15
- corekit/utils/void.py +8 -8
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
- python_corekit-0.3.0.dist-info/RECORD +145 -0
- corekit/constants.py +0 -45
- corekit/exceptions/http/exceptions.py +0 -37
- corekit/files/pickle.py +0 -12
- python_corekit-0.1.1.dist-info/RECORD +0 -125
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
corekit/api/routers.py
CHANGED
|
@@ -12,18 +12,45 @@ the subscript syntax::
|
|
|
12
12
|
return await router.handler.list_users()
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import inspect
|
|
15
16
|
from typing import Any, Generic, Type, TypeVar, cast
|
|
16
17
|
|
|
17
18
|
from fastapi import APIRouter, FastAPI
|
|
18
19
|
|
|
19
20
|
from corekit.api.handler import BaseHandler
|
|
20
21
|
from corekit.observability.benchmarkable import Benchmarkable
|
|
22
|
+
from corekit.registry import OrderedRegistry
|
|
21
23
|
|
|
22
|
-
__all__ = ["SimpleRouter", "SmartRouter"]
|
|
24
|
+
__all__ = ["CatchAllRouter", "SimpleRouter", "SmartRouter", "router_registry"]
|
|
23
25
|
|
|
24
26
|
H = TypeVar("H", bound=BaseHandler)
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
class _RouterRegistry(OrderedRegistry["SimpleRouter"]):
|
|
30
|
+
"""
|
|
31
|
+
Every ``SimpleRouter`` built in this process, in construction order.
|
|
32
|
+
|
|
33
|
+
``Application.discover_routers`` imports a package to populate this, then
|
|
34
|
+
mounts what is left unclaimed. A router that has been included into another
|
|
35
|
+
router claims itself, so only top-level routers are mounted.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def unclaimed(self) -> list["SimpleRouter"]:
|
|
40
|
+
"""
|
|
41
|
+
Top-level routers, with any catch-all last.
|
|
42
|
+
|
|
43
|
+
Sorting is stable, so everything else keeps construction order.
|
|
44
|
+
"""
|
|
45
|
+
return sorted(
|
|
46
|
+
self.where(lambda router: not router.claimed),
|
|
47
|
+
key=lambda router: isinstance(router, CatchAllRouter),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
router_registry = _RouterRegistry()
|
|
52
|
+
|
|
53
|
+
|
|
27
54
|
class SimpleRouter(APIRouter, Benchmarkable):
|
|
28
55
|
"""
|
|
29
56
|
An APIRouter that can log, time itself, and mount itself onto a parent.
|
|
@@ -39,13 +66,54 @@ class SimpleRouter(APIRouter, Benchmarkable):
|
|
|
39
66
|
APIRouter.__init__(self, **kwargs)
|
|
40
67
|
Benchmarkable.__init__(self)
|
|
41
68
|
|
|
69
|
+
# Discovery needs to see every router that was built, and to tell a
|
|
70
|
+
# top-level one from a sub-router. Both facts are recorded here rather
|
|
71
|
+
# than inferred later, when the import that created them is long done.
|
|
72
|
+
self._claimed = False
|
|
73
|
+
self._origin_module = self._caller_module()
|
|
74
|
+
router_registry.register(self)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def claimed(self) -> bool:
|
|
78
|
+
"""Whether a parent router has taken ownership of this one."""
|
|
79
|
+
return self._claimed
|
|
80
|
+
|
|
81
|
+
def claim(self) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Mark this router as owned by a parent, so discovery skips it.
|
|
84
|
+
|
|
85
|
+
Called by ``include`` rather than by hand: a router that a parent has
|
|
86
|
+
already mounted must not also be mounted at the application root.
|
|
87
|
+
"""
|
|
88
|
+
self._claimed = True
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _caller_module() -> str:
|
|
92
|
+
"""
|
|
93
|
+
The module that constructed this router.
|
|
94
|
+
|
|
95
|
+
``type(self).__module__`` names where the class is defined, which is
|
|
96
|
+
always corekit; discovery logs need where the instance was built. Walk
|
|
97
|
+
out of corekit's own frames to find it.
|
|
98
|
+
"""
|
|
99
|
+
for frame in inspect.stack()[1:]:
|
|
100
|
+
module = frame.frame.f_globals.get("__name__", "")
|
|
101
|
+
if not module.startswith("corekit."):
|
|
102
|
+
return module
|
|
103
|
+
return "<unknown>"
|
|
104
|
+
|
|
42
105
|
def include(self, parent: APIRouter | FastAPI, *args: Any, **kwargs: Any) -> None:
|
|
43
106
|
"""
|
|
44
107
|
Add this router to a parent router or application.
|
|
45
108
|
|
|
46
109
|
The inverse of ``parent.include_router(self)``, so a module can hand
|
|
47
110
|
itself over: ``admin_router.include(app)``.
|
|
111
|
+
|
|
112
|
+
Including into another router claims this one, so ``discover_routers``
|
|
113
|
+
will not mount it a second time at the application root.
|
|
48
114
|
"""
|
|
115
|
+
if isinstance(parent, APIRouter):
|
|
116
|
+
self.claim()
|
|
49
117
|
parent.include_router(self, *args, **kwargs)
|
|
50
118
|
|
|
51
119
|
|
|
@@ -113,3 +181,43 @@ class SmartRouter(SimpleRouter, Generic[H]):
|
|
|
113
181
|
Replace the handler. Assigning a double is the supported way to test a router.
|
|
114
182
|
"""
|
|
115
183
|
self._handler = handler
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class CatchAllRouter(SmartRouter[H]):
|
|
187
|
+
"""
|
|
188
|
+
The router of last resort, mounted after every other route.
|
|
189
|
+
|
|
190
|
+
A single-page app needs one of these: a ``/{path:path}`` route that serves
|
|
191
|
+
``index.html`` so the client-side router can take over. Mounted in the wrong
|
|
192
|
+
order it shadows the entire API, because it matches everything.
|
|
193
|
+
|
|
194
|
+
Being a distinct type rather than an ordering flag means that position is a
|
|
195
|
+
property of what the router *is*. ``discover_routers`` always mounts it last,
|
|
196
|
+
and a second instance is refused -- two catch-alls means one of them silently
|
|
197
|
+
never matches, which is not a state worth allowing.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
_instance: "CatchAllRouter[Any] | None" = None
|
|
201
|
+
|
|
202
|
+
def __init__(self, handler: H | None = None, **kwargs: Any) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Build the catch-all. Raises if one already exists in this process.
|
|
205
|
+
"""
|
|
206
|
+
existing = CatchAllRouter._instance
|
|
207
|
+
if existing is not None:
|
|
208
|
+
raise RuntimeError(
|
|
209
|
+
f"A CatchAllRouter already exists ({existing._origin_module}). "
|
|
210
|
+
f"Only one may exist: it matches every unmatched path, so a second "
|
|
211
|
+
f"could never receive a request. Delete one, or make it a SmartRouter "
|
|
212
|
+
f"with an explicit prefix."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
super().__init__(handler=handler, **kwargs)
|
|
216
|
+
CatchAllRouter._instance = self
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def reset(cls) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Forget the registered instance. For tests that build more than one app.
|
|
222
|
+
"""
|
|
223
|
+
cls._instance = None
|
corekit/concurrency/__init__.py
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
Concurrency primitives: per-thread storage, workers, and parallel mapping.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from corekit.concurrency.decorators import parallelize
|
|
5
|
+
from corekit.concurrency.decorators import allow_sync, parallelize
|
|
6
6
|
from corekit.concurrency.thread_local import ThreadLocalRegistry
|
|
7
7
|
from corekit.concurrency.worker import ThreadWorker
|
|
8
8
|
|
|
9
|
-
__all__ = ["ThreadLocalRegistry", "ThreadWorker", "parallelize"]
|
|
9
|
+
__all__ = ["ThreadLocalRegistry", "ThreadWorker", "allow_sync", "parallelize"]
|
|
@@ -18,19 +18,26 @@ machine rather than an error message.
|
|
|
18
18
|
|
|
19
19
|
Results arrive out of order, because they arrive as they complete. Pass
|
|
20
20
|
``ordered=True`` to get them in the order the inputs were given.
|
|
21
|
+
|
|
22
|
+
``allow_sync`` wraps an async function so a script can call it directly, while
|
|
23
|
+
an already-running event loop still gets a coroutine to await.
|
|
21
24
|
"""
|
|
22
25
|
|
|
26
|
+
import asyncio
|
|
23
27
|
import functools
|
|
28
|
+
import logging
|
|
29
|
+
from collections.abc import Awaitable, Callable
|
|
24
30
|
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
25
|
-
from typing import Any,
|
|
31
|
+
from typing import Any, Iterable, Iterator, ParamSpec, TypeVar
|
|
26
32
|
|
|
27
33
|
from corekit.config import get_settings
|
|
28
|
-
from corekit.observability import Loggable
|
|
29
34
|
|
|
30
|
-
__all__ = ["parallelize"]
|
|
35
|
+
__all__ = ["parallelize", "allow_sync"]
|
|
36
|
+
|
|
37
|
+
_logger = logging.getLogger("parallelize")
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
P = ParamSpec("P")
|
|
40
|
+
R = TypeVar("R")
|
|
34
41
|
|
|
35
42
|
|
|
36
43
|
def parallelize(
|
|
@@ -70,3 +77,23 @@ def parallelize(
|
|
|
70
77
|
return wrapper
|
|
71
78
|
|
|
72
79
|
return decorator
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def allow_sync(async_func: Callable[P, Awaitable[R]]) -> Callable[P, R | Awaitable[R]]:
|
|
83
|
+
"""
|
|
84
|
+
Call an async function from sync code, or return its coroutine if a loop is running.
|
|
85
|
+
|
|
86
|
+
A script gets the result back immediately. Inside an async app the wrapper
|
|
87
|
+
returns the coroutine, so the caller must await it — calling the wrapper
|
|
88
|
+
and ignoring the result will not run the function.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@functools.wraps(async_func)
|
|
92
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | Awaitable[R]:
|
|
93
|
+
try:
|
|
94
|
+
asyncio.get_running_loop()
|
|
95
|
+
except RuntimeError:
|
|
96
|
+
return asyncio.run(async_func(*args, **kwargs))
|
|
97
|
+
return async_func(*args, **kwargs)
|
|
98
|
+
|
|
99
|
+
return wrapper
|
|
@@ -19,7 +19,7 @@ import threading
|
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
21
21
|
from corekit.observability.loggable import Loggable
|
|
22
|
-
from corekit.registry import
|
|
22
|
+
from corekit.registry import normalize_key
|
|
23
23
|
|
|
24
24
|
__all__ = ["ThreadLocalRegistry"]
|
|
25
25
|
|
|
@@ -59,7 +59,7 @@ class ThreadLocalRegistry(threading.local, Loggable):
|
|
|
59
59
|
turn SQLConnection into "sqlconnection" rather than "sql_connection".
|
|
60
60
|
"""
|
|
61
61
|
name = key.__name__ if isinstance(key, type) else str(key)
|
|
62
|
-
return
|
|
62
|
+
return normalize_key(name).replace("-", "_")
|
|
63
63
|
|
|
64
64
|
def get(self, key: Any, fallback: Any = None) -> Any:
|
|
65
65
|
"""
|
corekit/concurrency/worker.py
CHANGED
|
@@ -1,65 +1,74 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import logging
|
|
3
|
-
import threading
|
|
4
|
-
import time
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class ThreadWorker(threading.Thread):
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
self
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
self.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def
|
|
35
|
-
self.
|
|
36
|
-
|
|
37
|
-
def
|
|
38
|
-
self.logger.info("
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ThreadWorker(threading.Thread):
|
|
8
|
+
"""
|
|
9
|
+
A daemon thread that waits for ``can_start``, then calls ``process`` once.
|
|
10
|
+
|
|
11
|
+
``timeout`` and ``loop`` are stored for a subclass that wants them.
|
|
12
|
+
``run`` does not enforce ``timeout`` and does not drive ``loop``.
|
|
13
|
+
``stop`` sets ``running`` so a subclass of ``process`` can notice it; it
|
|
14
|
+
does not interrupt a thread already inside ``process``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
can_start: threading.Event,
|
|
20
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
21
|
+
timeout: int = 3,
|
|
22
|
+
delay: float = 1.0,
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(daemon=True)
|
|
25
|
+
self.loop = loop
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self.delay = delay
|
|
28
|
+
self.running = True # Control flag for stopping the thread
|
|
29
|
+
self.can_start: threading.Event = can_start
|
|
30
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
31
|
+
self._logging_prefix = f"[{self.__class__.__name__}]"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def logging_prefix(self) -> str:
|
|
35
|
+
return self._logging_prefix
|
|
36
|
+
|
|
37
|
+
def info(self, message: str) -> None:
|
|
38
|
+
self.logger.info(f"{self.logging_prefix}: {message}")
|
|
39
|
+
|
|
40
|
+
def warning(self, message: str) -> None:
|
|
41
|
+
self.logger.warning(f"{self.logging_prefix}: {message}")
|
|
42
|
+
|
|
43
|
+
def error(self, message: str) -> None:
|
|
44
|
+
self.logger.error(f"{self.logging_prefix}: {message}")
|
|
45
|
+
|
|
46
|
+
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
47
|
+
self.logger.info("Updating event loop")
|
|
48
|
+
self.loop = loop
|
|
49
|
+
|
|
50
|
+
def stop(self) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Stops the worker gracefully
|
|
53
|
+
"""
|
|
54
|
+
self.running = False
|
|
55
|
+
self.info("Stopping ThreadWorker")
|
|
56
|
+
|
|
57
|
+
def run(self) -> None:
|
|
58
|
+
self.info("ThreadWorker started")
|
|
59
|
+
try:
|
|
60
|
+
self.info("ThreadWorker waiting until able to start")
|
|
61
|
+
self.can_start.wait()
|
|
62
|
+
|
|
63
|
+
self.info("can_start flag set. Starting ThreadWorker")
|
|
64
|
+
self.process()
|
|
65
|
+
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
self.error(f"Error in ThreadWorker: {exc}")
|
|
68
|
+
time.sleep(self.delay)
|
|
69
|
+
|
|
70
|
+
finally:
|
|
71
|
+
self.info("ThreadWorker ending")
|
|
72
|
+
|
|
73
|
+
def process(self) -> None:
|
|
74
|
+
raise NotImplementedError()
|
corekit/config/loader.py
CHANGED
|
@@ -9,6 +9,8 @@ settings know only their own shape.
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
12
14
|
from corekit.config.settings import ENV_PREFIX, CorekitSettings
|
|
13
15
|
from corekit.config.sources import ConfigFileSource, ConfigSource, EnvironmentSource, PyprojectSource
|
|
14
16
|
from corekit.observability import Loggable
|
|
@@ -72,11 +74,12 @@ class SettingsLoader(Loggable):
|
|
|
72
74
|
"""
|
|
73
75
|
Read every source and construct the settings.
|
|
74
76
|
|
|
75
|
-
Invalid configuration
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
Invalid configuration drops the bad section and keeps the rest.
|
|
78
|
+
A bad thread count must not discard a valid salt or serialization
|
|
79
|
+
key that arrived from another section or the environment. If what
|
|
80
|
+
remains still will not validate, the last resort is defaults --
|
|
81
|
+
corekit has to remain importable. The log line is the difference
|
|
82
|
+
between this and silently ignoring it.
|
|
80
83
|
"""
|
|
81
84
|
values: dict[str, Any] = {}
|
|
82
85
|
for source in self.sources():
|
|
@@ -84,10 +87,44 @@ class SettingsLoader(Loggable):
|
|
|
84
87
|
|
|
85
88
|
try:
|
|
86
89
|
return CorekitSettings(**values)
|
|
90
|
+
except ValidationError as exc:
|
|
91
|
+
cleaned, dropped = self._without_invalid_sections(values, exc)
|
|
92
|
+
if dropped:
|
|
93
|
+
self.error(
|
|
94
|
+
f"Invalid corekit configuration in {', '.join(dropped)}; "
|
|
95
|
+
f"those sections fall back to defaults: {exc}"
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
return CorekitSettings(**cleaned)
|
|
99
|
+
except ValidationError as retry_exc:
|
|
100
|
+
self.error(f"Invalid corekit configuration, falling back to defaults: {retry_exc}")
|
|
101
|
+
return CorekitSettings()
|
|
102
|
+
self.error(f"Invalid corekit configuration, falling back to defaults: {exc}")
|
|
103
|
+
return CorekitSettings()
|
|
87
104
|
except Exception as exc:
|
|
88
105
|
self.error(f"Invalid corekit configuration, falling back to defaults: {exc}")
|
|
89
106
|
return CorekitSettings()
|
|
90
107
|
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _without_invalid_sections(values: dict[str, Any], exc: ValidationError) -> tuple[dict[str, Any], list[str]]:
|
|
110
|
+
"""
|
|
111
|
+
Drop each section named in a validation error, leaving the others.
|
|
112
|
+
|
|
113
|
+
A section is the first element of the error location. A failure that
|
|
114
|
+
names no section cannot be isolated, so nothing is dropped and the
|
|
115
|
+
caller falls back to defaults.
|
|
116
|
+
"""
|
|
117
|
+
dropped: list[str] = []
|
|
118
|
+
for error in exc.errors():
|
|
119
|
+
loc = error.get("loc") or ()
|
|
120
|
+
if not loc:
|
|
121
|
+
continue
|
|
122
|
+
section = str(loc[0])
|
|
123
|
+
if section not in dropped:
|
|
124
|
+
dropped.append(section)
|
|
125
|
+
cleaned = {key: value for key, value in values.items() if key not in dropped}
|
|
126
|
+
return cleaned, dropped
|
|
127
|
+
|
|
91
128
|
@staticmethod
|
|
92
129
|
def _merge(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
|
93
130
|
"""
|
corekit/config/settings.py
CHANGED
|
@@ -35,6 +35,10 @@ class StandardsSettings(BaseModel):
|
|
|
35
35
|
|
|
36
36
|
Off by default. These are conventions, not library invariants, and crashing
|
|
37
37
|
a consumer's application over a missing docstring would be hostile.
|
|
38
|
+
|
|
39
|
+
``strict_mode`` makes a ``SmartRegistry`` refuse a second write to a
|
|
40
|
+
normalized key instead of warning and replacing. It does not imply
|
|
41
|
+
``require_handler_docstrings``; that check stays its own flag.
|
|
38
42
|
"""
|
|
39
43
|
|
|
40
44
|
require_handler_docstrings: bool = False
|
|
@@ -80,7 +84,13 @@ class ConcurrencySettings(BaseModel):
|
|
|
80
84
|
@staticmethod
|
|
81
85
|
def cpu_default() -> int:
|
|
82
86
|
"""
|
|
83
|
-
A
|
|
87
|
+
A machine-sized thread count, for a caller that asks for one.
|
|
88
|
+
|
|
89
|
+
Not what ``resolve`` or ``parallelize`` use when the caller is silent.
|
|
90
|
+
Those use ``default_threads``. Pass this in when the work should
|
|
91
|
+
scale with the machine::
|
|
92
|
+
|
|
93
|
+
parallelize(num_threads=ConcurrencySettings.cpu_default())
|
|
84
94
|
"""
|
|
85
95
|
return min(32, (os.cpu_count() or 1) * 5)
|
|
86
96
|
|
corekit/connections/__init__.py
CHANGED
|
@@ -17,7 +17,12 @@ implementations of the abstraction directly above them, and there is no useful
|
|
|
17
17
|
way to think about one without the other.
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
-
from corekit.connections.connectable import
|
|
20
|
+
from corekit.connections.connectable import (
|
|
21
|
+
Connectable,
|
|
22
|
+
ConnectableType,
|
|
23
|
+
ConnectionPreference,
|
|
24
|
+
ReadOnlyConnectionError,
|
|
25
|
+
)
|
|
21
26
|
from corekit.connections.decorators import connect
|
|
22
27
|
from corekit.connections.registry import ConnectionRegistry, registry
|
|
23
28
|
|
|
@@ -26,6 +31,7 @@ __all__ = [
|
|
|
26
31
|
"ConnectableType",
|
|
27
32
|
"ConnectionPreference",
|
|
28
33
|
"ConnectionRegistry",
|
|
34
|
+
"ReadOnlyConnectionError",
|
|
29
35
|
"connect",
|
|
30
36
|
"registry",
|
|
31
37
|
]
|
|
@@ -21,22 +21,40 @@ class without importing it directly.
|
|
|
21
21
|
from abc import ABC, abstractmethod
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
+
from corekit.exceptions import InternalCoreException, Retryability
|
|
24
25
|
from corekit.observability.loggable import Loggable
|
|
25
26
|
from corekit.registry import SmartRegistry
|
|
26
27
|
from corekit.schemas.enum import StringEnum
|
|
27
28
|
|
|
28
|
-
__all__ = ["Connectable", "ConnectableType", "ConnectionPreference"]
|
|
29
|
+
__all__ = ["Connectable", "ConnectableType", "ConnectionPreference", "ReadOnlyConnectionError"]
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
class ConnectionPreference(StringEnum):
|
|
32
33
|
"""
|
|
33
|
-
|
|
34
|
+
A stored hint for which path a caller prefers.
|
|
35
|
+
|
|
36
|
+
The base class records this and does not consult it. ``connect`` and
|
|
37
|
+
``async_connect`` follow the method the caller invoked, and ``@connect``
|
|
38
|
+
always opens with ``with``. A subclass may read the hint; nothing here
|
|
39
|
+
switches paths because of it.
|
|
34
40
|
"""
|
|
35
41
|
|
|
36
42
|
ASYNC = "async"
|
|
37
43
|
SYNC = "sync"
|
|
38
44
|
|
|
39
45
|
|
|
46
|
+
class ReadOnlyConnectionError(InternalCoreException):
|
|
47
|
+
"""
|
|
48
|
+
Raised when a write is attempted on a connection opened read-only.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, connection: str) -> None:
|
|
52
|
+
super().__init__(
|
|
53
|
+
message=f"{connection} is read-only",
|
|
54
|
+
retryable=Retryability.NON_RETRYABLE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
40
58
|
class Connectable(Loggable, ABC):
|
|
41
59
|
"""
|
|
42
60
|
Base class for anything with a connection lifecycle.
|
|
@@ -97,10 +115,20 @@ class Connectable(Loggable, ABC):
|
|
|
97
115
|
@property
|
|
98
116
|
def connection_preference(self) -> ConnectionPreference:
|
|
99
117
|
"""
|
|
100
|
-
|
|
118
|
+
The path hint recorded at construction. Not used to open the connection.
|
|
101
119
|
"""
|
|
102
120
|
return self._connection_preference
|
|
103
121
|
|
|
122
|
+
def require_writable(self) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Raise if this connection was opened for reading only.
|
|
125
|
+
|
|
126
|
+
Corekit's own write methods call this. The base class does not wrap
|
|
127
|
+
every method, so a subclass with its own writes should call it too.
|
|
128
|
+
"""
|
|
129
|
+
if self._read_only:
|
|
130
|
+
raise ReadOnlyConnectionError(type(self).__name__)
|
|
131
|
+
|
|
104
132
|
def __repr__(self) -> str:
|
|
105
133
|
return f"{self.__class__.__name__}(connected={self.is_connected})"
|
|
106
134
|
|
|
@@ -190,11 +218,24 @@ class Connectable(Loggable, ABC):
|
|
|
190
218
|
self.debug(f"Terminating {self.__class__.__name__} connection")
|
|
191
219
|
self._disconnect()
|
|
192
220
|
|
|
221
|
+
def _async_is_open(self) -> bool:
|
|
222
|
+
"""
|
|
223
|
+
Whether the async path is already up.
|
|
224
|
+
|
|
225
|
+
``is_connected`` is the sync flag. Redis keeps a process-wide sync pool
|
|
226
|
+
and a separate async client, so a sync pool must not stand in for the
|
|
227
|
+
async client. Classes without ``is_async_connected`` use ``is_connected``.
|
|
228
|
+
"""
|
|
229
|
+
for cls in type(self).mro():
|
|
230
|
+
if "is_async_connected" in cls.__dict__:
|
|
231
|
+
return bool(self.is_async_connected)
|
|
232
|
+
return self.is_connected
|
|
233
|
+
|
|
193
234
|
async def async_connect(self, force_reconnect: bool = False) -> None:
|
|
194
235
|
"""
|
|
195
236
|
Connect asynchronously if not already connected.
|
|
196
237
|
"""
|
|
197
|
-
if self.
|
|
238
|
+
if self._async_is_open() and not force_reconnect:
|
|
198
239
|
return
|
|
199
240
|
|
|
200
241
|
self.debug(f"Establishing async {self.__class__.__name__} connection")
|