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
@@ -1,12 +1,27 @@
1
1
  import functools
2
+ import inspect
2
3
  import logging
3
4
  from typing import Any, Callable
4
5
 
5
6
  from corekit.utils import raise_exc, void
7
+ from corekit.utils.coercion import safe_tuple
6
8
 
7
9
  logger = logging.getLogger(__name__)
8
10
 
9
11
 
12
+ def _log_text(ex: Exception) -> str:
13
+ """
14
+ Prefer a backend log line when the exception provides one.
15
+
16
+ Public exceptions stringify to the user-facing copy. Logging that drops
17
+ the internal message.
18
+ """
19
+ for_log = getattr(ex, "for_log", None)
20
+ if callable(for_log):
21
+ return for_log()
22
+ return str(ex)
23
+
24
+
10
25
  def exception_handler(
11
26
  ignore: list[type[Exception]] | None = None,
12
27
  callback: Callable = raise_exc,
@@ -15,29 +30,43 @@ def exception_handler(
15
30
  """
16
31
  A decorator to handle exceptions in a class method or function.
17
32
 
33
+ Works on both sync and async functions. An async function keeps an async
34
+ wrapper, so awaiting it still works.
35
+
18
36
  Args:
19
37
  ignore: exception classes to suppress. These do not propagate.
20
38
  callback: called when an exception is raised that is not ignored.
21
39
  ignore_callback: called when a suppressed exception is raised.
22
40
  """
23
- exceptions_to_ignore = tuple(ignore) if ignore else tuple()
41
+ exceptions_to_ignore = safe_tuple(ignore)
24
42
 
25
43
  def decorator(func):
44
+ def handle(ex: Exception, args: tuple, kwargs: dict) -> Any:
45
+ text = _log_text(ex)
46
+ if isinstance(ex, exceptions_to_ignore):
47
+ logger.warning(f"Suppressed exception in {func.__name__}: {text}")
48
+ logger.info(f"Invoking callback function: {ignore_callback.__name__}")
49
+ return ignore_callback(ex, *args, **kwargs)
50
+
51
+ logger.error(f"Exception in {func.__name__}: {text}", exc_info=True)
52
+ return callback(ex, *args, **kwargs)
53
+
26
54
  @functools.wraps(func)
27
55
  def wrapper(*args: Any, **kwargs: Any) -> Any:
28
56
  try:
29
57
  return func(*args, **kwargs)
30
58
  except Exception as ex:
31
- # If in ignore list, suppress and invoke the ignore_callback function
32
- if isinstance(ex, exceptions_to_ignore):
33
- logger.warning(f"Suppressed exception in {func.__name__}: {ex}")
34
- logger.info(f"Invoking callback function: {ignore_callback.__name__}")
35
- return ignore_callback(ex, *args, **kwargs)
59
+ return handle(ex, args, kwargs)
36
60
 
37
- # Log the exception and invoke the callback function, returning its return value (if any)
38
- logger.error(f"Exception in {func.__name__}: {ex}", exc_info=True)
39
- return callback(ex, *args, **kwargs)
61
+ @functools.wraps(func)
62
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
63
+ try:
64
+ return await func(*args, **kwargs)
65
+ except Exception as ex:
66
+ return handle(ex, args, kwargs)
40
67
 
68
+ if inspect.iscoroutinefunction(func):
69
+ return async_wrapper
41
70
  return wrapper
42
71
 
43
72
  return decorator
@@ -42,11 +42,12 @@ class Watchdog(Benchmarkable):
42
42
  background daemon, and one unreachable container should not stop it.
43
43
  """
44
44
 
45
- def __init__(self, docker_host: str | None = None, enforce_label: bool = False) -> None:
45
+ def __init__(self, docker_host: str | None = None, enforce_label: bool = True) -> None:
46
46
  """
47
47
  :param docker_host: Docker endpoint. Defaults to the local socket.
48
48
  :param enforce_label: restrict actions to containers carrying the
49
- managed label.
49
+ managed label. On by default; turn it off only when this watchdog
50
+ is meant to control every container on the host.
50
51
  """
51
52
  super().__init__()
52
53
  self.docker_host = docker_host or DEFAULT_DOCKER_HOST
@@ -96,27 +97,29 @@ class Watchdog(Benchmarkable):
96
97
  return container.labels.get(WATCHDOG_LABEL) == WATCHDOG_MANAGED
97
98
  return True
98
99
 
99
- def _start_container(self, container: Container) -> None:
100
+ def _start_container(self, container: Container) -> bool:
100
101
  try:
101
102
  container.reload()
102
103
  if container.status != "running" and self._label_validator(container):
103
104
  container.start()
104
105
  self.info(f"Started container: {container.name}")
105
- else:
106
- self.info(f"Container {container.name!r} is already running or not managed.")
106
+ return True
107
+ self.info(f"Container {container.name!r} is already running or not managed.")
107
108
  except APIError as exc:
108
109
  self.error(f"Failed to start container {container.name}: {exc}")
110
+ return False
109
111
 
110
- def _stop_container(self, container: Container) -> None:
112
+ def _stop_container(self, container: Container) -> bool:
111
113
  try:
112
114
  container.reload()
113
115
  if container.status == "running" and self._label_validator(container):
114
116
  container.stop()
115
117
  self.info(f"Stopped container: {container.name}")
116
- else:
117
- self.info(f"Container {container.name!r} is not running or not managed.")
118
+ return True
119
+ self.info(f"Container {container.name!r} is not running or not managed.")
118
120
  except APIError as exc:
119
121
  self.error(f"Failed to stop container {container.name}: {exc}")
122
+ return False
120
123
 
121
124
  def _by_name(self, name: str) -> Container | None:
122
125
  """
@@ -161,62 +164,78 @@ class Watchdog(Benchmarkable):
161
164
  for container in self._container_iterator(filter_func=self._get_label_filter(label, value), all=True):
162
165
  self._start_container(container)
163
166
 
164
- def start_container_by_name(self, name: str) -> None:
167
+ def start_container_by_name(self, name: str) -> bool:
165
168
  """
166
- Start one container by name.
169
+ Start one container by name. Returns whether it was started.
167
170
  """
168
171
  container = self._by_name(name)
169
- if container is not None:
170
- self._start_container(container)
172
+ if container is None:
173
+ return False
174
+ return self._start_container(container)
171
175
 
172
- def stop_container_by_name(self, name: str) -> None:
176
+ def stop_container_by_name(self, name: str) -> bool:
173
177
  """
174
- Stop one container by name.
178
+ Stop one container by name. Returns whether it was stopped.
175
179
  """
176
180
  container = self._by_name(name)
177
- if container is not None:
178
- self._stop_container(container)
181
+ if container is None:
182
+ return False
183
+ return self._stop_container(container)
179
184
 
180
- def restart_container_by_name(self, name: str) -> None:
185
+ def restart_container_by_name(self, name: str) -> bool:
181
186
  """
182
- Stop and then start one container by name.
187
+ Restart one container by name, honoring the managed label.
188
+
189
+ Returns whether the container was restarted. A missing or unmanaged
190
+ container is not touched.
183
191
  """
184
192
  container = self._by_name(name)
185
193
  if container is None:
186
- return
187
- self._stop_container(container)
188
- self._start_container(container)
194
+ return False
195
+ try:
196
+ container.reload()
197
+ if not self._label_validator(container):
198
+ self.info(f"Container {container.name!r} is not managed.")
199
+ return False
200
+ container.restart()
201
+ self.info(f"Restarted container: {container.name}")
202
+ return True
203
+ except APIError as exc:
204
+ self.error(f"Failed to restart container {name}: {exc}")
205
+ return False
189
206
 
190
- def pause_container_by_name(self, name: str) -> None:
207
+ def pause_container_by_name(self, name: str) -> bool:
191
208
  """
192
- Pause one running container by name.
209
+ Pause one running container by name. Returns whether it was paused.
193
210
  """
194
211
  container = self._by_name(name)
195
212
  if container is None:
196
- return
213
+ return False
197
214
  try:
198
215
  container.reload()
199
216
  if container.status == "running" and self._label_validator(container):
200
217
  container.pause()
201
218
  self.info(f"Paused container: {container.name}")
202
- else:
203
- self.info(f"Container {container.name!r} is not running or not managed.")
219
+ return True
220
+ self.info(f"Container {container.name!r} is not running or not managed.")
204
221
  except APIError as exc:
205
222
  self.error(f"Failed to pause container {name}: {exc}")
223
+ return False
206
224
 
207
- def unpause_container_by_name(self, name: str) -> None:
225
+ def unpause_container_by_name(self, name: str) -> bool:
208
226
  """
209
- Unpause one paused container by name.
227
+ Unpause one paused container by name. Returns whether it was unpaused.
210
228
  """
211
229
  container = self._by_name(name)
212
230
  if container is None:
213
- return
231
+ return False
214
232
  try:
215
233
  container.reload()
216
234
  if container.status == "paused" and self._label_validator(container):
217
235
  container.unpause()
218
236
  self.info(f"Unpaused container: {container.name}")
219
- else:
220
- self.info(f"Container {container.name!r} is not paused or not managed.")
237
+ return True
238
+ self.info(f"Container {container.name!r} is not paused or not managed.")
221
239
  except APIError as exc:
222
240
  self.error(f"Failed to unpause container {name}: {exc}")
241
+ return False
corekit/etl/__init__.py CHANGED
@@ -18,7 +18,7 @@ The run loop streams, so memory use is bounded by the batch size rather than by
18
18
  how much data the source holds.
19
19
  """
20
20
 
21
- from corekit.etl.connection import BaseConnection, ConnectionDetails
21
+ from corekit.etl.connection import BaseConnection, ConnectionDetails, ETLSource
22
22
  from corekit.etl.extract.extractor import BaseETLExtractor
23
23
  from corekit.etl.extract.schemas import BaseExtractedItemModel, ExtractedItem
24
24
  from corekit.etl.load.loader import BaseETLLoader
@@ -30,6 +30,7 @@ from corekit.etl.transform.transformer import BaseETLTransformer
30
30
 
31
31
  __all__ = [
32
32
  "BaseConnection",
33
+ "ETLSource",
33
34
  "BaseETLExtractor",
34
35
  "BaseETLLoader",
35
36
  "BaseETLOrchestrator",
corekit/etl/connection.py CHANGED
@@ -1,44 +1,46 @@
1
- from enum import Enum
2
- from typing import NamedTuple
3
-
4
-
5
- class ConnectionDetails(NamedTuple):
6
- name: str
7
- description: str
8
-
9
-
10
- class BaseConnection(Enum):
11
- """
12
- Base enum for ETL Connections. All ETL connections must inherit from this class.
13
-
14
- Example:
15
- class MyConnectionEnum(BaseConnection):
16
- MY_FIRST_CONNECTION = ConnectionDetails(
17
- name="my_first_connection",
18
- description="My First Connection"
19
- )
20
- ANOTHER_CONNECTION = ConnectionDetails(
21
- name="another_connection",
22
- description="Another Connection"
23
- )
24
- """
25
-
26
- @classmethod
27
- def from_name(cls, name: str) -> "BaseConnection":
28
- # TODO: Make sure this is efficient
29
- for connection in cls:
30
- if connection.value.name == name:
31
- return connection
32
- raise ValueError(f"Connection {name} not found")
33
-
34
- def get_name(self) -> str:
35
- """
36
- Helper method for fetching the name of the connection
37
- """
38
- return self.value.name
39
-
40
- def get_description(self) -> str:
41
- """
42
- Helper method for fetching the name of the connection
43
- """
44
- return self.value.description
1
+ from enum import Enum
2
+ from typing import NamedTuple
3
+
4
+
5
+ class ConnectionDetails(NamedTuple):
6
+ name: str
7
+ description: str
8
+
9
+
10
+ class ETLSource(Enum):
11
+ """
12
+ A named source label for an extracted item.
13
+
14
+ This is not a connection. It does not open a socket, a session, or a
15
+ ``Connectable``. It only names where a row came from so a transformer can
16
+ tell sources apart.
17
+
18
+ Example:
19
+ class UserSource(ETLSource):
20
+ USERS = ConnectionDetails(name="users", description="User records")
21
+ """
22
+
23
+ @classmethod
24
+ def from_name(cls, name: str) -> "ETLSource":
25
+ # TODO: Make sure this is efficient
26
+ for connection in cls:
27
+ if connection.value.name == name:
28
+ return connection
29
+ raise ValueError(f"Connection {name} not found")
30
+
31
+ def get_name(self) -> str:
32
+ """
33
+ Helper method for fetching the name of the connection
34
+ """
35
+ return self.value.name
36
+
37
+ def get_description(self) -> str:
38
+ """
39
+ Helper method for fetching the name of the connection
40
+ """
41
+ return self.value.description
42
+
43
+
44
+ # The old name implied a second connection system. Keep it so existing imports
45
+ # still resolve; new code should use ETLSource.
46
+ BaseConnection = ETLSource
@@ -1,30 +1,23 @@
1
1
  from abc import ABC, abstractmethod
2
2
  from typing import Any, AsyncIterable, Callable
3
3
 
4
- from corekit.etl.connection import BaseConnection
5
4
  from corekit.etl.extract.schemas import ExtractedItem
6
- from corekit.http.client import BaseApiClient
7
5
  from corekit.observability.loggable import Loggable
8
6
 
9
7
 
10
8
  class BaseETLExtractor(Loggable, ABC):
11
9
  """
12
- Base class for ETL extractors.
10
+ Yields items from a source.
11
+
12
+ The runner constructs this with no arguments, so a subclass that needs a
13
+ client or other dependency should default it or be passed in as an
14
+ instance. ``client`` is optional and unused by the base class.
13
15
  """
14
16
 
15
- def __init__(self, client: BaseApiClient) -> None:
17
+ def __init__(self, client: Any | None = None) -> None:
16
18
  super().__init__()
17
19
  self.client = client
18
20
 
19
- @property
20
- @abstractmethod
21
- def connection(self) -> BaseConnection:
22
- """
23
- Abstract property that must be implemented by child classes.
24
- This property should return the integration object that the extractor is associated with
25
- """
26
- raise NotImplementedError
27
-
28
21
  @abstractmethod
29
22
  def _extraction_methods(self) -> list[Callable[[Any], Any]]:
30
23
  """
@@ -159,12 +159,19 @@ class BaseETLOrchestrator(Loggable):
159
159
  transformer: BaseETLTransformer | None = None,
160
160
  loader: BaseETLLoader | None = None,
161
161
  batch_size: int | None = None,
162
+ on_item_error: str = "raise",
162
163
  ) -> None:
163
164
  """
164
165
  Build a pipeline, optionally overriding any stage with an instance.
166
+
167
+ ``on_item_error`` is ``raise`` (stop, leaving earlier batches written)
168
+ or ``skip`` (log the item and continue). Transform is synchronous.
165
169
  """
170
+ if on_item_error not in ("raise", "skip"):
171
+ raise ValueError("on_item_error must be 'raise' or 'skip'")
166
172
  super().__init__()
167
173
  self.batch_size = batch_size if batch_size is not None else type(self).batch_size
174
+ self.on_item_error = on_item_error
168
175
  self._extractor = extractor
169
176
  self._transformer = transformer
170
177
  self._loader = loader
@@ -192,10 +199,20 @@ class BaseETLOrchestrator(Loggable):
192
199
  loader = self._build(self._loader, "loader")
193
200
 
194
201
  count = 0
202
+ skipped = 0
195
203
  async for extracted_item in extractor.extract():
196
- loader.add_item(transformer.transform(extracted_item))
204
+ try:
205
+ # transform is synchronous; do not await it.
206
+ transformed = transformer.transform(extracted_item)
207
+ except Exception:
208
+ if self.on_item_error == "raise":
209
+ raise
210
+ skipped += 1
211
+ self.exception(f"{type(self).__name__} skipped an item")
212
+ continue
213
+ loader.add_item(transformed)
197
214
  await loader.load(min_ops=self.batch_size)
198
215
  count += 1
199
216
 
200
217
  await loader.flush()
201
- self.info(f"{type(self).__name__} processed {count} items")
218
+ self.info(f"{type(self).__name__} processed {count} items, skipped {skipped}")
corekit/etl/schemas.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from pydantic import BaseModel
2
2
 
3
- from corekit.etl.connection import BaseConnection
3
+ from corekit.etl.connection import ETLSource
4
4
  from corekit.schemas.types import UnknownDict
5
5
 
6
6
 
@@ -9,7 +9,7 @@ class BaseItem(BaseModel):
9
9
  Base model for all ETL items
10
10
  """
11
11
 
12
- connection: BaseConnection
12
+ connection: ETLSource
13
13
 
14
14
 
15
15
  class RawAPIResponse(BaseItem):
@@ -18,7 +18,10 @@ class BaseETLTransformer(Loggable, ABC):
18
18
 
19
19
  def transform(self, item: ExtractedItem) -> TransformedItem:
20
20
  """
21
- Primary method to run the transformer.
21
+ Turn one extracted item into a loadable one.
22
+
23
+ Synchronous on purpose. The orchestrator calls this directly and does
24
+ not await it.
22
25
  """
23
26
  data_type = item.get_data_type()
24
27
  mapping = self._extracted_item_type_to_method
@@ -11,7 +11,7 @@ constant::
11
11
  EventPublisher.for_resource("minecraft", "server", "survival")
12
12
  # -> channel "minecraft:server:survival"
13
13
 
14
- Requires the ``redis`` extra.
14
+ Redis is a hard dependency of corekit; no extra install is required.
15
15
  """
16
16
 
17
17
  from typing import Any
corekit/events/reader.py CHANGED
@@ -8,7 +8,9 @@ disappears -- so no consumer has to repeat it.
8
8
  Everything here uses Redis's async client. The synchronous client's
9
9
  ``get_message`` blocks the thread it is called on, which in an async server is
10
10
  the event loop, stalling every other request in the process while one client
11
- waits for an event that may never come.
11
+ waits for an event that may never come. The async client's ``get_message``
12
+ returns immediately unless it is given a timeout, so the wait has to be
13
+ asked for there rather than wrapped around a call that already finished.
12
14
  """
13
15
 
14
16
  import asyncio
@@ -21,9 +23,6 @@ from corekit.observability.loggable import Loggable
21
23
 
22
24
  __all__ = ["RedisChannelReader"]
23
25
 
24
- # How long to yield to the event loop when the socket has nothing ready.
25
- IDLE_SLEEP_SECONDS = 0.01
26
-
27
26
 
28
27
  class RedisChannelReader(Loggable):
29
28
  """
@@ -88,27 +87,33 @@ class RedisChannelReader(Loggable):
88
87
  """
89
88
  Return the next decoded payload.
90
89
 
91
- Returns ``None`` when nothing arrived before ``timeout``, so a caller
92
- can act on the silence -- sending a keepalive, say -- rather than
93
- blocking forever.
90
+ Returns ``None`` only when nothing arrived before ``timeout``. The wait
91
+ happens inside Redis ``get_message``: calling it with no timeout
92
+ returns immediately, and wrapping that in ``asyncio.wait_for`` never
93
+ blocks. ``timeout=None`` waits until a payload arrives.
94
94
  """
95
- try:
96
- message = await asyncio.wait_for(
97
- self._pubsub.get_message(ignore_subscribe_messages=True),
98
- timeout=timeout,
95
+ loop = asyncio.get_running_loop()
96
+ deadline = None if timeout is None else loop.time() + timeout
97
+ while True:
98
+ remaining = None if deadline is None else deadline - loop.time()
99
+ if remaining is not None and remaining <= 0:
100
+ return None
101
+
102
+ message = await self._pubsub.get_message(
103
+ ignore_subscribe_messages=True,
104
+ timeout=remaining,
99
105
  )
100
- except asyncio.TimeoutError:
101
- return None
102
-
103
- if message is None:
104
- # Nothing ready. Yield rather than spin.
105
- await asyncio.sleep(IDLE_SLEEP_SECONDS)
106
- return None
106
+ if message is None:
107
+ # None before the deadline is an ignored subscribe ack, not
108
+ # silence. Only the expired wait is a keepalive.
109
+ if deadline is None or loop.time() < deadline:
110
+ continue
111
+ return None
107
112
 
108
- if message.get(PubSubField.TYPE.value) not in (None, PubSubMessageType.MESSAGE.value):
109
- return None
113
+ if message.get(PubSubField.TYPE.value) not in (None, PubSubMessageType.MESSAGE.value):
114
+ continue
110
115
 
111
- return self._decode(message.get(PubSubField.DATA.value))
116
+ return self._decode(message.get(PubSubField.DATA.value))
112
117
 
113
118
  def _decode(self, raw: Any) -> Any:
114
119
  """
corekit/events/sse.py CHANGED
@@ -93,7 +93,10 @@ class SSEStream(Loggable):
93
93
  while True:
94
94
  payload = await self._reader.next_payload(timeout=self.keepalive_interval)
95
95
  if payload is None:
96
- yield SSEFrame.comment()
96
+ # None means the wait expired. A disabled keepalive never
97
+ # asks for a timeout, so it must not emit a comment either.
98
+ if self.keepalive_interval is not None:
99
+ yield SSEFrame.comment()
97
100
  continue
98
101
  yield self._frame_for(payload)
99
102
 
@@ -13,6 +13,7 @@ from typing import Any
13
13
  from corekit.events.enum import MessageField
14
14
  from corekit.events.reader import RedisChannelReader
15
15
  from corekit.observability.loggable import Loggable
16
+ from corekit.utils.coercion import safe_dict, safe_list
16
17
 
17
18
  __all__ = ["WebSocketBridge"]
18
19
 
@@ -44,7 +45,7 @@ class WebSocketBridge(Loggable):
44
45
  super().__init__()
45
46
  self.channel = channel
46
47
  self.websocket = websocket
47
- self.terminal_statuses = terminal_statuses or []
48
+ self.terminal_statuses = safe_list(terminal_statuses)
48
49
  self.timeout = timeout
49
50
  self._reader = RedisChannelReader(channel, url=url)
50
51
 
@@ -56,16 +57,23 @@ class WebSocketBridge(Loggable):
56
57
  return False
57
58
  if payload.get(MessageField.TYPE.value) != STATUS_UPDATE_EVENT:
58
59
  return False
59
- data = payload.get(MessageField.DATA.value) or {}
60
+ data = safe_dict(payload.get(MessageField.DATA.value))
60
61
  return data.get(MessageField.STATUS.value) in self.terminal_statuses
61
62
 
62
63
  def _expired(self, started: float) -> bool:
63
64
  """
64
65
  Whether the bridge has outlived its timeout.
65
66
  """
67
+ remaining = self._remaining(started)
68
+ return remaining is not None and remaining <= 0
69
+
70
+ def _remaining(self, started: float) -> float | None:
71
+ """
72
+ Seconds left before the bridge times out, or None if it never does.
73
+ """
66
74
  if self.timeout is None:
67
- return False
68
- return asyncio.get_event_loop().time() - started > self.timeout
75
+ return None
76
+ return self.timeout - (asyncio.get_running_loop().time() - started)
69
77
 
70
78
  async def run(self) -> None:
71
79
  """
@@ -74,21 +82,27 @@ class WebSocketBridge(Loggable):
74
82
  if not await self._reader.open():
75
83
  return
76
84
 
77
- started = asyncio.get_event_loop().time()
85
+ started = asyncio.get_running_loop().time()
78
86
  try:
79
87
  while True:
80
- payload = await self._reader.next_payload(timeout=self.timeout)
81
-
82
- if payload is not None:
83
- await self.websocket.send_json(payload)
84
- if self._is_terminal(payload):
85
- self.info(f"Bridge on {self.channel} reached a terminal status")
86
- break
88
+ remaining = self._remaining(started)
89
+ if remaining is not None and remaining <= 0:
90
+ self.warning(f"Bridge on {self.channel} timed out")
91
+ break
87
92
 
88
- if self._expired(started):
93
+ payload = await self._reader.next_payload(timeout=remaining)
94
+ if payload is None:
95
+ # The wait expired. That is the timeout, not a reason to poll again.
96
+ if remaining is None:
97
+ continue
89
98
  self.warning(f"Bridge on {self.channel} timed out")
90
99
  break
91
100
 
101
+ await self.websocket.send_json(payload)
102
+ if self._is_terminal(payload):
103
+ self.info(f"Bridge on {self.channel} reached a terminal status")
104
+ break
105
+
92
106
  except asyncio.CancelledError:
93
107
  raise
94
108
  except Exception as exc:
@@ -0,0 +1,33 @@
1
+ """
2
+ Exception base classes.
3
+
4
+ ``CoreException`` is abstract. Raise ``InternalCoreException`` for backend-only
5
+ failures, or ``PublicCoreException`` when there is copy that is safe to show
6
+ a user. ``CoreHTTPException`` is the FastAPI-facing public exception.
7
+ """
8
+
9
+ from corekit.exceptions.base import (
10
+ CoreException,
11
+ CoreHTTPException,
12
+ ExponentialBackoffTimeoutException,
13
+ InternalCoreException,
14
+ NonRetryableCoreHTTPException,
15
+ PublicCoreException,
16
+ RetryableCoreHTTPException,
17
+ )
18
+ from corekit.exceptions.enum import Retryability
19
+ from corekit.exceptions.types import ArbitraryCoreExceptionType, CoreExceptionType, CoreHTTPExceptionType
20
+
21
+ __all__ = [
22
+ "ArbitraryCoreExceptionType",
23
+ "CoreException",
24
+ "CoreExceptionType",
25
+ "CoreHTTPException",
26
+ "CoreHTTPExceptionType",
27
+ "ExponentialBackoffTimeoutException",
28
+ "InternalCoreException",
29
+ "NonRetryableCoreHTTPException",
30
+ "PublicCoreException",
31
+ "Retryability",
32
+ "RetryableCoreHTTPException",
33
+ ]