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
corekit/__init__.py ADDED
File without changes
@@ -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)
@@ -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
+ ]
@@ -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()