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
corekit/__init__.py
ADDED
|
File without changes
|
corekit/api/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI building blocks: handlers, routers and responses.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.api.handler import BaseHandler
|
|
6
|
+
from corekit.api.responses import SSEResponse
|
|
7
|
+
from corekit.api.routers import SimpleRouter, SmartRouter
|
|
8
|
+
|
|
9
|
+
__all__ = ["BaseHandler", "SSEResponse", "SimpleRouter", "SmartRouter"]
|
corekit/api/handler.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for business-logic handlers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from corekit.config import get_settings
|
|
8
|
+
from corekit.observability.benchmarkable import Benchmarkable
|
|
9
|
+
from corekit.registry import SmartRegistry
|
|
10
|
+
|
|
11
|
+
__all__ = ["BaseHandler"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _has_multiline_docstring(cls: type) -> bool:
|
|
15
|
+
"""
|
|
16
|
+
Report whether a class carries a docstring spanning more than one line.
|
|
17
|
+
"""
|
|
18
|
+
doc = cls.__doc__
|
|
19
|
+
return bool(doc) and "\n" in doc.strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseHandler(Benchmarkable):
|
|
23
|
+
"""
|
|
24
|
+
Base class for all business logic handlers.
|
|
25
|
+
|
|
26
|
+
Every subclass registers itself into a shared registry, so handlers can be
|
|
27
|
+
discovered by name at runtime. Inherits logging from Loggable and split
|
|
28
|
+
timing from Benchmarkable.
|
|
29
|
+
|
|
30
|
+
Docstring enforcement is opt-in. Set ``require_handler_docstrings`` in your
|
|
31
|
+
corekit config (or ``COREKIT_REQUIRE_HANDLER_DOCSTRINGS=true``) to require a
|
|
32
|
+
multiline docstring on every handler; set ``__require_doc__`` on a subclass
|
|
33
|
+
to override the configured value for that branch of the hierarchy.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__registry__: SmartRegistry = SmartRegistry()
|
|
37
|
+
|
|
38
|
+
# None means "defer to configuration". A subclass may set True or False to
|
|
39
|
+
# opt in or out regardless of what the configuration says.
|
|
40
|
+
__require_doc__: bool | None = None
|
|
41
|
+
|
|
42
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Validate and register every subclass at class-definition time.
|
|
45
|
+
"""
|
|
46
|
+
super().__init_subclass__(**kwargs)
|
|
47
|
+
|
|
48
|
+
# Read from cls, not from BaseHandler, so subclasses can genuinely
|
|
49
|
+
# override this. Reading the base class attribute silently ignored
|
|
50
|
+
# every per-subclass opt-out.
|
|
51
|
+
require_doc = cls.__require_doc__
|
|
52
|
+
if require_doc is None:
|
|
53
|
+
require_doc = get_settings().standards.require_handler_docstrings
|
|
54
|
+
|
|
55
|
+
if require_doc and not _has_multiline_docstring(cls):
|
|
56
|
+
raise TypeError(
|
|
57
|
+
f"Handler '{cls.__name__}' must have a descriptive multiline docstring. "
|
|
58
|
+
f"Set __require_doc__ = False on the class, or disable "
|
|
59
|
+
f"require_handler_docstrings in your corekit config, to opt out."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
BaseHandler.__registry__[cls.__name__] = cls
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def get_handler_types(cls) -> SmartRegistry:
|
|
66
|
+
"""
|
|
67
|
+
Return the registry of all known BaseHandler subclasses.
|
|
68
|
+
"""
|
|
69
|
+
return cls.__registry__
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def get_handler_by_name(cls, name: str) -> type["BaseHandler"] | None:
|
|
73
|
+
"""
|
|
74
|
+
Retrieve a handler class by name, using the registry's key normalization.
|
|
75
|
+
"""
|
|
76
|
+
return cls.__registry__.get(name)
|
corekit/api/responses.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response types.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Any, AsyncGenerator
|
|
7
|
+
|
|
8
|
+
from fastapi.responses import StreamingResponse
|
|
9
|
+
|
|
10
|
+
__all__ = ["SSEResponse"]
|
|
11
|
+
|
|
12
|
+
# no-cache stops the browser reusing a stream; X-Accel-Buffering stops nginx
|
|
13
|
+
# holding frames back until its buffer fills, which makes a live stream arrive
|
|
14
|
+
# in batches or not at all.
|
|
15
|
+
DEFAULT_HEADERS = MappingProxyType({"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SSEResponse(StreamingResponse):
|
|
19
|
+
"""
|
|
20
|
+
A StreamingResponse configured for Server-Sent Events.
|
|
21
|
+
|
|
22
|
+
@router.get("/events")
|
|
23
|
+
async def events(channel: str) -> SSEResponse:
|
|
24
|
+
return SSEResponse(event_stream(channel))
|
|
25
|
+
|
|
26
|
+
Pass ``keep_alive=True`` for long-lived streams behind a proxy that closes
|
|
27
|
+
idle connections.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
content: AsyncGenerator[str, None],
|
|
33
|
+
headers: dict[str, str] | None = None,
|
|
34
|
+
keep_alive: bool = False,
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> None:
|
|
37
|
+
headers = dict(DEFAULT_HEADERS) if headers is None else dict(headers)
|
|
38
|
+
if keep_alive:
|
|
39
|
+
headers["Connection"] = "keep-alive"
|
|
40
|
+
super().__init__(content=content, media_type="text/event-stream", headers=headers, **kwargs)
|
corekit/api/routers.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Routers that carry their own handler.
|
|
3
|
+
|
|
4
|
+
``SimpleRouter`` is a FastAPI ``APIRouter`` that can log, time itself and add
|
|
5
|
+
itself to a parent. ``SmartRouter`` additionally binds a handler class through
|
|
6
|
+
the subscript syntax::
|
|
7
|
+
|
|
8
|
+
router = SmartRouter[AdminHandler](route_prefix="/admin", tags=["Admin"])
|
|
9
|
+
|
|
10
|
+
@router.get("/users")
|
|
11
|
+
async def list_users() -> UserResponse:
|
|
12
|
+
return await router.handler.list_users()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any, Generic, Type, TypeVar, cast
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, FastAPI
|
|
18
|
+
|
|
19
|
+
from corekit.api.handler import BaseHandler
|
|
20
|
+
from corekit.observability.benchmarkable import Benchmarkable
|
|
21
|
+
|
|
22
|
+
__all__ = ["SimpleRouter", "SmartRouter"]
|
|
23
|
+
|
|
24
|
+
H = TypeVar("H", bound=BaseHandler)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimpleRouter(APIRouter, Benchmarkable):
|
|
28
|
+
"""
|
|
29
|
+
An APIRouter that can log, time itself, and mount itself onto a parent.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, route_prefix: str | None = None, **kwargs: Any) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Accepts every APIRouter keyword, plus ``route_prefix`` as an alias for ``prefix``.
|
|
35
|
+
"""
|
|
36
|
+
if route_prefix and "prefix" not in kwargs:
|
|
37
|
+
kwargs["prefix"] = route_prefix
|
|
38
|
+
|
|
39
|
+
APIRouter.__init__(self, **kwargs)
|
|
40
|
+
Benchmarkable.__init__(self)
|
|
41
|
+
|
|
42
|
+
def include(self, parent: APIRouter | FastAPI, *args: Any, **kwargs: Any) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Add this router to a parent router or application.
|
|
45
|
+
|
|
46
|
+
The inverse of ``parent.include_router(self)``, so a module can hand
|
|
47
|
+
itself over: ``admin_router.include(app)``.
|
|
48
|
+
"""
|
|
49
|
+
parent.include_router(self, *args, **kwargs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SmartRouter(SimpleRouter, Generic[H]):
|
|
53
|
+
"""
|
|
54
|
+
A router bound to a handler class.
|
|
55
|
+
|
|
56
|
+
``SmartRouter[MyHandler]`` mints a subclass carrying ``MyHandler``. The
|
|
57
|
+
handler is constructed on first access rather than at import time, so tests
|
|
58
|
+
can substitute one by assigning to ``router.handler`` without the real
|
|
59
|
+
handler ever being built.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
_handler_class: Type[H] | None = None
|
|
63
|
+
|
|
64
|
+
# Subscripting the same handler twice returns the same class, so
|
|
65
|
+
# SmartRouter[X] is SmartRouter[X] holds and repeated subscripts do not
|
|
66
|
+
# mint throwaway types.
|
|
67
|
+
__bound_cache__: dict[Any, type] = {}
|
|
68
|
+
|
|
69
|
+
def __class_getitem__(cls, item: Type[H]) -> Any:
|
|
70
|
+
"""
|
|
71
|
+
Bind a handler class, returning a cached dynamic subclass.
|
|
72
|
+
"""
|
|
73
|
+
cache_key = (cls, item)
|
|
74
|
+
cached = SmartRouter.__bound_cache__.get(cache_key)
|
|
75
|
+
if cached is not None:
|
|
76
|
+
return cached
|
|
77
|
+
|
|
78
|
+
new_cls = type(f"{cls.__name__}_{item.__name__}", (cls,), {"_handler_class": item})
|
|
79
|
+
SmartRouter.__bound_cache__[cache_key] = new_cls
|
|
80
|
+
return new_cls
|
|
81
|
+
|
|
82
|
+
def __init__(self, handler: H | None = None, **kwargs: Any) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Build the router, optionally with an explicit handler instance.
|
|
85
|
+
|
|
86
|
+
Without one, the class bound via ``SmartRouter[MyHandler]`` is
|
|
87
|
+
instantiated lazily on first use of ``handler``.
|
|
88
|
+
"""
|
|
89
|
+
SimpleRouter.__init__(self, **kwargs)
|
|
90
|
+
|
|
91
|
+
if handler is None and self._handler_class is None:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"{type(self).__name__} has no handler. Use a concrete type -- "
|
|
94
|
+
f"SmartRouter[MyHandler](...) -- or pass handler=..."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
self._handler: H | None = handler
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def handler(self) -> H:
|
|
101
|
+
"""
|
|
102
|
+
Return the handler, constructing it on first access.
|
|
103
|
+
"""
|
|
104
|
+
if self._handler is None:
|
|
105
|
+
handler_class = cast(Type[H], self._handler_class)
|
|
106
|
+
self.info(f"Assembling {handler_class.__name__} for {type(self).__name__}")
|
|
107
|
+
self._handler = handler_class()
|
|
108
|
+
return self._handler
|
|
109
|
+
|
|
110
|
+
@handler.setter
|
|
111
|
+
def handler(self, handler: H) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Replace the handler. Assigning a double is the supported way to test a router.
|
|
114
|
+
"""
|
|
115
|
+
self._handler = handler
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Concurrency primitives: per-thread storage, workers, and parallel mapping.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.concurrency.decorators import parallelize
|
|
6
|
+
from corekit.concurrency.thread_local import ThreadLocalRegistry
|
|
7
|
+
from corekit.concurrency.worker import ThreadWorker
|
|
8
|
+
|
|
9
|
+
__all__ = ["ThreadLocalRegistry", "ThreadWorker", "parallelize"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Running a function over many inputs at once.
|
|
3
|
+
|
|
4
|
+
``@parallelize`` turns a single-item function into one that takes an iterable
|
|
5
|
+
and yields results as they finish::
|
|
6
|
+
|
|
7
|
+
@parallelize()
|
|
8
|
+
def fetch(url: str) -> Response:
|
|
9
|
+
return client.get(url)
|
|
10
|
+
|
|
11
|
+
for response in fetch(urls):
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
The thread count comes from configuration unless the caller names one, and is
|
|
15
|
+
capped either way -- see ``ConcurrencySettings``. A pool is cheap to ask for and
|
|
16
|
+
expensive to get wrong, and the failure mode of getting it wrong is a hung
|
|
17
|
+
machine rather than an error message.
|
|
18
|
+
|
|
19
|
+
Results arrive out of order, because they arrive as they complete. Pass
|
|
20
|
+
``ordered=True`` to get them in the order the inputs were given.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import functools
|
|
24
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
25
|
+
from typing import Any, Callable, Iterable, Iterator
|
|
26
|
+
|
|
27
|
+
from corekit.config import get_settings
|
|
28
|
+
from corekit.observability import Loggable
|
|
29
|
+
|
|
30
|
+
__all__ = ["parallelize"]
|
|
31
|
+
|
|
32
|
+
_logger = Loggable()
|
|
33
|
+
_logger.logger = _logger.logger.getChild("parallelize")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parallelize(
|
|
37
|
+
num_threads: int | None = None,
|
|
38
|
+
ordered: bool = False,
|
|
39
|
+
raise_on_error: bool = True,
|
|
40
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Iterator[Any]]]:
|
|
41
|
+
"""
|
|
42
|
+
Run a function over an iterable of inputs across a thread pool.
|
|
43
|
+
|
|
44
|
+
:param num_threads: workers to use. Defaults to the configured
|
|
45
|
+
``concurrency.default_threads``, and is clamped to ``max_threads``.
|
|
46
|
+
:param ordered: yield results in input order rather than completion order.
|
|
47
|
+
:param raise_on_error: propagate the first failure. When False, failures are
|
|
48
|
+
logged and skipped -- which loses results silently, so it is opt-in.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Iterator[Any]]:
|
|
52
|
+
@functools.wraps(func)
|
|
53
|
+
def wrapper(items: Iterable[Any], *args: Any, **kwargs: Any) -> Iterator[Any]:
|
|
54
|
+
"""
|
|
55
|
+
Submit every item and yield the results.
|
|
56
|
+
"""
|
|
57
|
+
workers = get_settings().concurrency.resolve(num_threads)
|
|
58
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
59
|
+
futures: list[Future] = [executor.submit(func, item, *args, **kwargs) for item in items]
|
|
60
|
+
|
|
61
|
+
pending = futures if ordered else as_completed(futures)
|
|
62
|
+
for future in pending:
|
|
63
|
+
try:
|
|
64
|
+
yield future.result()
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
if raise_on_error:
|
|
67
|
+
raise
|
|
68
|
+
_logger.warning(f"{func.__name__} failed for one item: {exc}")
|
|
69
|
+
|
|
70
|
+
return wrapper
|
|
71
|
+
|
|
72
|
+
return decorator
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-thread storage.
|
|
3
|
+
|
|
4
|
+
``ThreadLocalRegistry`` is a key/value store whose contents are private to the
|
|
5
|
+
thread that wrote them::
|
|
6
|
+
|
|
7
|
+
store = ThreadLocalRegistry()
|
|
8
|
+
store.set("request_id", "abc-123")
|
|
9
|
+
store.get("request_id") # "abc-123" on this thread, None on any other
|
|
10
|
+
|
|
11
|
+
Keys may be strings or classes; a class is reduced to a readable attribute name,
|
|
12
|
+
so a store can be addressed by type without the caller inventing a name for it.
|
|
13
|
+
|
|
14
|
+
Subclass it to manage what it holds -- see ``ConnectionRegistry``, which opens
|
|
15
|
+
and closes the connections it stores.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from corekit.observability.loggable import Loggable
|
|
22
|
+
from corekit.registry import SmartRegistry
|
|
23
|
+
|
|
24
|
+
__all__ = ["ThreadLocalRegistry"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ThreadLocalRegistry(threading.local, Loggable):
|
|
28
|
+
"""
|
|
29
|
+
A key/value store with per-thread contents.
|
|
30
|
+
|
|
31
|
+
Subclassing ``threading.local`` means one shared object serves every thread
|
|
32
|
+
with its own values, so what one thread stores is never visible to another.
|
|
33
|
+
``__init__`` therefore runs once per thread, which gives each its own logger.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Attributes belonging to the object rather than to what it stores. They
|
|
37
|
+
# share __dict__ with stored values, so they are excluded from anything
|
|
38
|
+
# that reports on contents.
|
|
39
|
+
_RESERVED = frozenset({"logger"})
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def _stored(self) -> dict[str, Any]:
|
|
46
|
+
"""
|
|
47
|
+
This thread's stored values, without the object's own attributes.
|
|
48
|
+
"""
|
|
49
|
+
return {key: value for key, value in self.__dict__.items() if key not in self._RESERVED}
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _key(key: Any) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Normalize a key to an attribute name.
|
|
55
|
+
|
|
56
|
+
A class is keyed by its name, so callers can address a slot by type.
|
|
57
|
+
Normalization goes through SmartRegistry so word boundaries are found
|
|
58
|
+
the same way everywhere, including inside acronyms: a naive rule would
|
|
59
|
+
turn SQLConnection into "sqlconnection" rather than "sql_connection".
|
|
60
|
+
"""
|
|
61
|
+
name = key.__name__ if isinstance(key, type) else str(key)
|
|
62
|
+
return SmartRegistry.__normalize_key__(name).replace("-", "_")
|
|
63
|
+
|
|
64
|
+
def get(self, key: Any, fallback: Any = None) -> Any:
|
|
65
|
+
"""
|
|
66
|
+
This thread's value for ``key``, or ``fallback``.
|
|
67
|
+
"""
|
|
68
|
+
return self.__dict__.get(self._key(key), fallback)
|
|
69
|
+
|
|
70
|
+
def set(self, key: Any, value: Any) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Store a value for this thread.
|
|
73
|
+
"""
|
|
74
|
+
self.__dict__[self._key(key)] = value
|
|
75
|
+
|
|
76
|
+
def clear(self, key: Any) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Forget this thread's value for ``key``.
|
|
79
|
+
"""
|
|
80
|
+
self.__dict__.pop(self._key(key), None)
|
|
81
|
+
|
|
82
|
+
def clear_all(self) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Forget everything this thread has stored.
|
|
85
|
+
"""
|
|
86
|
+
for key in self._stored:
|
|
87
|
+
del self.__dict__[key]
|
|
88
|
+
|
|
89
|
+
def keys(self) -> tuple[str, ...]:
|
|
90
|
+
"""
|
|
91
|
+
The keys this thread has stored.
|
|
92
|
+
"""
|
|
93
|
+
return tuple(self._stored)
|
|
94
|
+
|
|
95
|
+
def __contains__(self, key: Any) -> bool:
|
|
96
|
+
return self._key(key) in self._stored
|
|
97
|
+
|
|
98
|
+
def __len__(self) -> int:
|
|
99
|
+
return len(self._stored)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ThreadWorker(threading.Thread):
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
can_start: threading.Event,
|
|
11
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
12
|
+
timeout: int = 3,
|
|
13
|
+
delay: float = 1.0,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(daemon=True)
|
|
16
|
+
self.loop = loop
|
|
17
|
+
self.timeout = timeout
|
|
18
|
+
self.delay = delay
|
|
19
|
+
self.running = True # Control flag for stopping the thread
|
|
20
|
+
self.can_start: threading.Event = can_start
|
|
21
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
22
|
+
self._logging_prefix = f"[{self.__class__.__name__}]"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def logging_prefix(self) -> str:
|
|
26
|
+
return self._logging_prefix
|
|
27
|
+
|
|
28
|
+
def info(self, message: str) -> None:
|
|
29
|
+
self.logger.info(f"{self.logging_prefix}: {message}")
|
|
30
|
+
|
|
31
|
+
def warning(self, message: str) -> None:
|
|
32
|
+
self.logger.warning(f"{self.logging_prefix}: {message}")
|
|
33
|
+
|
|
34
|
+
def error(self, message: str) -> None:
|
|
35
|
+
self.logger.error(f"{self.logging_prefix}: {message}")
|
|
36
|
+
|
|
37
|
+
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
38
|
+
self.logger.info("Updating event loop")
|
|
39
|
+
self.loop = loop
|
|
40
|
+
|
|
41
|
+
def stop(self) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Stops the worker gracefully
|
|
44
|
+
"""
|
|
45
|
+
self.running = False
|
|
46
|
+
self.info("Stopping ThreadWorker")
|
|
47
|
+
|
|
48
|
+
def run(self) -> None:
|
|
49
|
+
self.info("ThreadWorker started")
|
|
50
|
+
try:
|
|
51
|
+
self.info("ThreadWorker waiting until able to start")
|
|
52
|
+
self.can_start.wait()
|
|
53
|
+
|
|
54
|
+
self.info("can_start flag set. Starting ThreadWorker")
|
|
55
|
+
self.process()
|
|
56
|
+
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
self.error(f"Error in ThreadWorker: {exc}")
|
|
59
|
+
time.sleep(self.delay)
|
|
60
|
+
|
|
61
|
+
finally:
|
|
62
|
+
self.info("ThreadWorker ending")
|
|
63
|
+
|
|
64
|
+
def process(self) -> None:
|
|
65
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Layered configuration for corekit.
|
|
3
|
+
|
|
4
|
+
Configuration is optional: corekit always looks for it, and never requires it.
|
|
5
|
+
|
|
6
|
+
from corekit.config import get_settings
|
|
7
|
+
|
|
8
|
+
get_settings().concurrency.max_threads
|
|
9
|
+
|
|
10
|
+
Precedence, highest first: an explicit argument, then ``COREKIT_``-prefixed
|
|
11
|
+
environment variables, then a config file, then the defaults.
|
|
12
|
+
|
|
13
|
+
Config files, first hit wins::
|
|
14
|
+
|
|
15
|
+
./corekit.toml
|
|
16
|
+
./pyproject.toml ([tool.corekit] table)
|
|
17
|
+
~/.config/corekit/config.toml
|
|
18
|
+
|
|
19
|
+
Nothing is read at import time. Settings resolve on first use, so importing
|
|
20
|
+
corekit can never fail because a variable is missing or a file has a typo.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from corekit.config.loader import SettingsLoader, get_settings, loader, reset_settings, set_settings
|
|
24
|
+
from corekit.config.settings import (
|
|
25
|
+
ConcurrencySettings,
|
|
26
|
+
CorekitSettings,
|
|
27
|
+
CryptoSettings,
|
|
28
|
+
DatabaseSettings,
|
|
29
|
+
RedisSettings,
|
|
30
|
+
SerializationSettings,
|
|
31
|
+
StandardsSettings,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ConcurrencySettings",
|
|
36
|
+
"CryptoSettings",
|
|
37
|
+
"DatabaseSettings",
|
|
38
|
+
"CorekitSettings",
|
|
39
|
+
"RedisSettings",
|
|
40
|
+
"SerializationSettings",
|
|
41
|
+
"SettingsLoader",
|
|
42
|
+
"StandardsSettings",
|
|
43
|
+
"get_settings",
|
|
44
|
+
"loader",
|
|
45
|
+
"reset_settings",
|
|
46
|
+
"set_settings",
|
|
47
|
+
]
|
corekit/config/loader.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assembling settings from their sources.
|
|
3
|
+
|
|
4
|
+
The layering -- explicit argument, then environment, then config file, then
|
|
5
|
+
defaults -- lives here, so a source knows only how to produce values and the
|
|
6
|
+
settings know only their own shape.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from corekit.config.settings import ENV_PREFIX, CorekitSettings
|
|
13
|
+
from corekit.config.sources import ConfigFileSource, ConfigSource, EnvironmentSource, PyprojectSource
|
|
14
|
+
from corekit.observability import Loggable
|
|
15
|
+
|
|
16
|
+
__all__ = ["SettingsLoader", "get_settings", "reset_settings", "set_settings"]
|
|
17
|
+
|
|
18
|
+
CONFIG_FILENAME = "corekit.toml"
|
|
19
|
+
USER_CONFIG_PATH = Path.home() / ".config" / "corekit" / "config.toml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SettingsLoader(Loggable):
|
|
23
|
+
"""
|
|
24
|
+
Builds a ``CorekitSettings`` from the available sources.
|
|
25
|
+
|
|
26
|
+
Holds the resolved settings for the process. Resolution is deferred until
|
|
27
|
+
first use, so importing corekit never reads the environment or touches the
|
|
28
|
+
filesystem -- and so can never fail because a variable is missing.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, start: Path | None = None) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.start = start
|
|
34
|
+
self._settings: CorekitSettings | None = None
|
|
35
|
+
|
|
36
|
+
def file_sources(self) -> list[ConfigFileSource]:
|
|
37
|
+
"""
|
|
38
|
+
The config files to consider, in priority order.
|
|
39
|
+
"""
|
|
40
|
+
base = self.start or Path.cwd()
|
|
41
|
+
sources: list[ConfigFileSource] = [
|
|
42
|
+
ConfigFileSource(base / CONFIG_FILENAME),
|
|
43
|
+
PyprojectSource(base / "pyproject.toml"),
|
|
44
|
+
ConfigFileSource(USER_CONFIG_PATH),
|
|
45
|
+
]
|
|
46
|
+
return sources
|
|
47
|
+
|
|
48
|
+
def active_file_source(self) -> ConfigFileSource | None:
|
|
49
|
+
"""
|
|
50
|
+
The first config file that exists, or None.
|
|
51
|
+
"""
|
|
52
|
+
return next((source for source in self.file_sources() if source.exists()), None)
|
|
53
|
+
|
|
54
|
+
def sources(self) -> list[ConfigSource]:
|
|
55
|
+
"""
|
|
56
|
+
Every source to read, lowest priority first.
|
|
57
|
+
|
|
58
|
+
The environment is read explicitly rather than left to BaseSettings.
|
|
59
|
+
Values passed to its constructor outrank the environment in pydantic's
|
|
60
|
+
own precedence, so handing it the file contents that way would let a
|
|
61
|
+
config file override an environment variable -- the reverse of what is
|
|
62
|
+
documented.
|
|
63
|
+
"""
|
|
64
|
+
found: list[ConfigSource] = []
|
|
65
|
+
file_source = self.active_file_source()
|
|
66
|
+
if file_source is not None:
|
|
67
|
+
found.append(file_source)
|
|
68
|
+
found.append(EnvironmentSource(ENV_PREFIX))
|
|
69
|
+
return found
|
|
70
|
+
|
|
71
|
+
def build(self) -> CorekitSettings:
|
|
72
|
+
"""
|
|
73
|
+
Read every source and construct the settings.
|
|
74
|
+
|
|
75
|
+
Invalid configuration falls back to defaults rather than raising:
|
|
76
|
+
corekit has to remain importable, and a library that refuses to load
|
|
77
|
+
because of a stray value in a file is worse than one that logs the
|
|
78
|
+
problem and carries on. The log line is the difference between this and
|
|
79
|
+
silently ignoring it.
|
|
80
|
+
"""
|
|
81
|
+
values: dict[str, Any] = {}
|
|
82
|
+
for source in self.sources():
|
|
83
|
+
values = self._merge(values, source.load())
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return CorekitSettings(**values)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
self.error(f"Invalid corekit configuration, falling back to defaults: {exc}")
|
|
89
|
+
return CorekitSettings()
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _merge(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
|
93
|
+
"""
|
|
94
|
+
Overlay one mapping on another, section by section.
|
|
95
|
+
|
|
96
|
+
A plain update would let a source that sets one field in a section
|
|
97
|
+
discard every other field the sections below it had set.
|
|
98
|
+
"""
|
|
99
|
+
merged = dict(base)
|
|
100
|
+
for key, value in incoming.items():
|
|
101
|
+
existing = merged.get(key)
|
|
102
|
+
if isinstance(existing, dict) and isinstance(value, dict):
|
|
103
|
+
merged[key] = SettingsLoader._merge(existing, value)
|
|
104
|
+
else:
|
|
105
|
+
merged[key] = value
|
|
106
|
+
return merged
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def settings(self) -> CorekitSettings:
|
|
110
|
+
"""
|
|
111
|
+
The settings for this process, built on first access.
|
|
112
|
+
"""
|
|
113
|
+
if self._settings is None:
|
|
114
|
+
self._settings = self.build()
|
|
115
|
+
return self._settings
|
|
116
|
+
|
|
117
|
+
@settings.setter
|
|
118
|
+
def settings(self, settings: CorekitSettings) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Install explicit settings, overriding every source.
|
|
121
|
+
"""
|
|
122
|
+
self._settings = settings
|
|
123
|
+
|
|
124
|
+
def reset(self) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Discard the cached settings so the next access re-resolves them.
|
|
127
|
+
"""
|
|
128
|
+
self._settings = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
#: The loader every caller shares.
|
|
132
|
+
loader = SettingsLoader()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_settings() -> CorekitSettings:
|
|
136
|
+
"""
|
|
137
|
+
The settings for this process.
|
|
138
|
+
"""
|
|
139
|
+
return loader.settings
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def set_settings(settings: CorekitSettings) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Install explicit settings, overriding every source.
|
|
145
|
+
"""
|
|
146
|
+
loader.settings = settings
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def reset_settings() -> None:
|
|
150
|
+
"""
|
|
151
|
+
Discard the cached settings. Intended for tests.
|
|
152
|
+
"""
|
|
153
|
+
loader.reset()
|