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.
Files changed (109) hide show
  1. corekit/api/__init__.py +18 -3
  2. corekit/api/application.py +275 -0
  3. corekit/api/lifespan.py +233 -0
  4. corekit/api/middleware.py +93 -0
  5. corekit/api/routers.py +109 -1
  6. corekit/concurrency/__init__.py +2 -2
  7. corekit/concurrency/decorators.py +32 -5
  8. corekit/concurrency/thread_local.py +2 -2
  9. corekit/concurrency/worker.py +74 -65
  10. corekit/config/loader.py +42 -5
  11. corekit/config/settings.py +11 -1
  12. corekit/connections/__init__.py +7 -1
  13. corekit/connections/connectable.py +45 -4
  14. corekit/connections/redis/connection.py +53 -10
  15. corekit/connections/sql/__init__.py +33 -4
  16. corekit/connections/sql/connection.py +56 -3
  17. corekit/connections/sql/fields/__init__.py +2 -2
  18. corekit/connections/sql/fields/jsonb.py +13 -6
  19. corekit/connections/sql/migration/__init__.py +9 -5
  20. corekit/connections/sql/migration/base.py +3 -3
  21. corekit/connections/sql/migration/operations.py +135 -44
  22. corekit/connections/sql/migration/registry.py +2 -2
  23. corekit/connections/sql/operations/__init__.py +24 -0
  24. corekit/connections/sql/operations/base.py +111 -0
  25. corekit/connections/sql/operations/statements.py +170 -0
  26. corekit/connections/sql/query.py +4 -62
  27. corekit/connections/sql/table.py +33 -29
  28. corekit/crypto/__init__.py +3 -1
  29. corekit/crypto/constants.py +2 -2
  30. corekit/crypto/hasher.py +9 -4
  31. corekit/data/__init__.py +8 -0
  32. corekit/data/dataset.py +8 -2
  33. corekit/data/expressions/__init__.py +10 -2
  34. corekit/data/expressions/comparison.py +142 -123
  35. corekit/data/expressions/expression.py +71 -98
  36. corekit/data/expressions/operator.py +39 -0
  37. corekit/data/expressions/target.py +21 -0
  38. corekit/data/record.py +147 -147
  39. corekit/data/stats.py +162 -157
  40. corekit/decorators/__init__.py +2 -2
  41. corekit/decorators/exception_handling.py +38 -9
  42. corekit/docker/watchdog.py +50 -31
  43. corekit/etl/__init__.py +2 -1
  44. corekit/etl/connection.py +46 -44
  45. corekit/etl/extract/extractor.py +6 -13
  46. corekit/etl/orchestrator.py +19 -2
  47. corekit/etl/schemas.py +2 -2
  48. corekit/etl/transform/transformer.py +4 -1
  49. corekit/events/publisher.py +1 -1
  50. corekit/events/reader.py +26 -21
  51. corekit/events/sse.py +4 -1
  52. corekit/events/websocket.py +27 -13
  53. corekit/exceptions/__init__.py +33 -0
  54. corekit/exceptions/base.py +139 -10
  55. corekit/exceptions/enum.py +17 -0
  56. corekit/exceptions/types.py +6 -6
  57. corekit/files/__init__.py +2 -4
  58. corekit/files/base.py +15 -2
  59. corekit/files/enum.py +0 -5
  60. corekit/files/json.py +16 -2
  61. corekit/http/__init__.py +51 -0
  62. corekit/http/api.py +24 -0
  63. corekit/http/client.py +100 -73
  64. corekit/http/exceptions.py +140 -0
  65. corekit/http/response.py +50 -1
  66. corekit/http/status.py +89 -0
  67. corekit/jobs/__init__.py +26 -0
  68. corekit/jobs/registry.py +87 -0
  69. corekit/jobs/runner.py +80 -0
  70. corekit/jobs/task.py +173 -0
  71. corekit/log_monitor/models.py +8 -2
  72. corekit/log_monitor/service.py +77 -38
  73. corekit/notifications/base.py +18 -10
  74. corekit/observability/__init__.py +12 -3
  75. corekit/observability/benchmarkable.py +23 -5
  76. corekit/observability/loggable.py +21 -0
  77. corekit/observability/request_context.py +188 -0
  78. corekit/observability/timing/timer.py +4 -2
  79. corekit/registry/__init__.py +12 -7
  80. corekit/registry/ordered.py +86 -0
  81. corekit/registry/registry.py +55 -14
  82. corekit/schemas/__init__.py +10 -0
  83. corekit/schemas/enum.py +70 -49
  84. corekit/schemas/models/arbitrary.py +11 -11
  85. corekit/schemas/pydantic/fields.py +35 -35
  86. corekit/schemas/types.py +45 -40
  87. corekit/serialization/__init__.py +24 -0
  88. corekit/serialization/pickle_file.py +61 -0
  89. corekit/serialization/serializable.py +22 -2
  90. corekit/serialization/serializer.py +10 -3
  91. corekit/utils/__init__.py +59 -5
  92. corekit/utils/coercion.py +118 -0
  93. corekit/utils/collections.py +124 -0
  94. corekit/utils/ids.py +61 -5
  95. corekit/utils/payload.py +112 -0
  96. corekit/utils/raise_exc.py +8 -8
  97. corekit/utils/text.py +56 -0
  98. corekit/utils/time.py +74 -21
  99. corekit/utils/validators.py +15 -15
  100. corekit/utils/void.py +8 -8
  101. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
  102. python_corekit-0.3.0.dist-info/RECORD +145 -0
  103. corekit/constants.py +0 -45
  104. corekit/exceptions/http/exceptions.py +0 -37
  105. corekit/files/pickle.py +0 -12
  106. python_corekit-0.1.1.dist-info/RECORD +0 -125
  107. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
  108. {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
  109. {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
@@ -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, Callable, Iterable, Iterator
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
- _logger = Loggable()
33
- _logger.logger = _logger.logger.getChild("parallelize")
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 SmartRegistry
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 SmartRegistry.__normalize_key__(name).replace("-", "_")
62
+ return normalize_key(name).replace("-", "_")
63
63
 
64
64
  def get(self, key: Any, fallback: Any = None) -> Any:
65
65
  """
@@ -1,65 +1,74 @@
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()
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 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.
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
  """
@@ -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 reasonable thread count for this machine, for callers that want one.
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
 
@@ -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 Connectable, ConnectableType, ConnectionPreference
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
- Whether a connection should prefer its sync or async path when both exist.
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
- Whether this connection prefers its sync or async path.
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.is_connected and not force_reconnect:
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")