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
File without changes
@@ -0,0 +1,15 @@
1
+ from pydantic import BaseModel
2
+
3
+ from corekit.etl.schemas import BaseItem
4
+
5
+
6
+ class BaseTransformedItemModel(BaseModel):
7
+ """
8
+ Parent model for transformed data. All transformed data models should inherit from this.
9
+ """
10
+
11
+ pass
12
+
13
+
14
+ class TransformedItem(BaseItem):
15
+ transformed_data: BaseTransformedItemModel
@@ -0,0 +1,28 @@
1
+ from abc import ABC, abstractmethod
2
+ from functools import cached_property
3
+ from typing import Any, Callable
4
+
5
+ from corekit.etl.extract.schemas import ExtractedItem
6
+ from corekit.etl.transform.schemas import TransformedItem
7
+ from corekit.observability.loggable import Loggable
8
+
9
+
10
+ class BaseETLTransformer(Loggable, ABC):
11
+ def __init__(self) -> None:
12
+ super().__init__()
13
+
14
+ @cached_property
15
+ @abstractmethod
16
+ def _extracted_item_type_to_method(self) -> dict[type, Callable[[Any], Any]]:
17
+ raise NotImplementedError
18
+
19
+ def transform(self, item: ExtractedItem) -> TransformedItem:
20
+ """
21
+ Primary method to run the transformer.
22
+ """
23
+ data_type = item.get_data_type()
24
+ mapping = self._extracted_item_type_to_method
25
+ if data_type not in mapping:
26
+ raise ValueError(f"No transformation method found for data type: {data_type}")
27
+
28
+ return mapping[data_type](item)
@@ -0,0 +1,38 @@
1
+ """
2
+ Real-time events over Redis Pub/Sub.
3
+
4
+ One side publishes::
5
+
6
+ EventPublisher("backups:events").publish("finished", {"size": "4.2GB"})
7
+
8
+ The other streams to a browser::
9
+
10
+ @router.get("/events")
11
+ async def events(channel: str) -> SSEResponse:
12
+ return SSEResponse(SSEStream(channel))
13
+
14
+ Publishers and subscribers meet on a channel name; ``for_resource`` builds the
15
+ conventional ``service:type:id`` form so both ends agree without sharing a
16
+ constant.
17
+ """
18
+
19
+ from corekit.events.enum import MessageField, PubSubField, PubSubMessageType, StreamEvent
20
+ from corekit.events.frames import SSEFrame
21
+ from corekit.events.models import BaseEvent
22
+ from corekit.events.publisher import EventPublisher
23
+ from corekit.events.reader import RedisChannelReader
24
+ from corekit.events.sse import SSEStream
25
+ from corekit.events.websocket import WebSocketBridge
26
+
27
+ __all__ = [
28
+ "BaseEvent",
29
+ "EventPublisher",
30
+ "MessageField",
31
+ "PubSubField",
32
+ "PubSubMessageType",
33
+ "RedisChannelReader",
34
+ "SSEFrame",
35
+ "SSEStream",
36
+ "StreamEvent",
37
+ "WebSocketBridge",
38
+ ]
corekit/events/enum.py ADDED
@@ -0,0 +1,58 @@
1
+ """
2
+ Wire-level names used by the event stream.
3
+
4
+ These are the strings that cross the network, so they are declared once rather
5
+ than repeated at each site that writes or reads one.
6
+
7
+ They are enums rather than bare constants because each set is closed: an event
8
+ is one of four kinds, a Redis message is one of three. A typo in a member name
9
+ is an AttributeError, where a typo in a string literal is a message nobody
10
+ receives.
11
+ """
12
+
13
+ from corekit.schemas.enum import StringEnum
14
+
15
+ __all__ = ["MessageField", "PubSubField", "PubSubMessageType", "StreamEvent"]
16
+
17
+
18
+ class StreamEvent(StringEnum):
19
+ """
20
+ Event names corekit itself emits, as distinct from application events.
21
+ """
22
+
23
+ CONNECTED = "connected"
24
+ INITIAL_STATE = "initial_state"
25
+ ERROR = "error"
26
+ MESSAGE = "message"
27
+
28
+
29
+ class MessageField(StringEnum):
30
+ """
31
+ Keys in an event's JSON body.
32
+ """
33
+
34
+ TYPE = "type"
35
+ DATA = "data"
36
+ STATUS = "status"
37
+ ERROR = "error"
38
+ CHANNEL = "channel"
39
+
40
+
41
+ class PubSubField(StringEnum):
42
+ """
43
+ Keys in a message as Redis delivers it.
44
+ """
45
+
46
+ TYPE = "type"
47
+ DATA = "data"
48
+
49
+
50
+ class PubSubMessageType(StringEnum):
51
+ """
52
+ Redis message kinds. Only MESSAGE carries a payload; the rest are
53
+ subscription bookkeeping.
54
+ """
55
+
56
+ MESSAGE = "message"
57
+ SUBSCRIBE = "subscribe"
58
+ UNSUBSCRIBE = "unsubscribe"
@@ -0,0 +1,51 @@
1
+ """
2
+ The Server-Sent Events wire format.
3
+
4
+ A frame is ``event: <name>``, then ``data: <json>``, then a blank line. The
5
+ blank line is what terminates it; without one a browser buffers indefinitely
6
+ waiting for more.
7
+ """
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from corekit.events.enum import MessageField, StreamEvent
13
+
14
+ __all__ = ["SSEFrame"]
15
+
16
+
17
+ class SSEFrame:
18
+ """
19
+ Renders the Server-Sent Events wire format.
20
+
21
+ A frame is ``event: <name>`` then ``data: <json>`` then a blank line. The
22
+ blank line terminates the frame; without it a browser buffers indefinitely
23
+ waiting for more.
24
+ """
25
+
26
+ TERMINATOR = "\n\n"
27
+
28
+ @classmethod
29
+ def event(cls, event_type: str, data: Any) -> str:
30
+ """
31
+ A named event carrying a JSON payload.
32
+ """
33
+ name = event_type.value if isinstance(event_type, StreamEvent) else event_type
34
+ return f"event: {name}\ndata: {json.dumps(data)}{cls.TERMINATOR}"
35
+
36
+ @classmethod
37
+ def error(cls, message: str) -> str:
38
+ """
39
+ An error the client should surface rather than retry silently.
40
+ """
41
+ return cls.event(StreamEvent.ERROR, {MessageField.ERROR.value: message})
42
+
43
+ @classmethod
44
+ def comment(cls, text: str = "keepalive") -> str:
45
+ """
46
+ A comment frame.
47
+
48
+ Browsers ignore comments, so they keep an idle connection from being
49
+ closed by an intermediate proxy without the client seeing anything.
50
+ """
51
+ return f": {text}{cls.TERMINATOR}"
@@ -0,0 +1,23 @@
1
+ """
2
+ Event payloads.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from corekit.utils.time import time_now
10
+
11
+ __all__ = ["BaseEvent"]
12
+
13
+
14
+ class BaseEvent(BaseModel):
15
+ """
16
+ An event: a type, a payload, and when it happened.
17
+
18
+ Subclass it to add fields; the wire format stays JSON either way.
19
+ """
20
+
21
+ type: str
22
+ data: dict[str, Any] = Field(default_factory=dict)
23
+ timestamp: str = Field(default_factory=lambda: time_now().isoformat())
@@ -0,0 +1,75 @@
1
+ """
2
+ Publishing events over Redis Pub/Sub.
3
+
4
+ publisher = EventPublisher("backups:events")
5
+ publisher.publish("finished", {"size": "4.2GB"})
6
+
7
+ Channels are just strings. ``for_resource`` builds the conventional
8
+ ``service:type:id`` form so publishers and subscribers agree without a shared
9
+ constant::
10
+
11
+ EventPublisher.for_resource("minecraft", "server", "survival")
12
+ # -> channel "minecraft:server:survival"
13
+
14
+ Requires the ``redis`` extra.
15
+ """
16
+
17
+ from typing import Any
18
+
19
+ from corekit.connections import connect
20
+ from corekit.connections.redis import RedisConnection
21
+ from corekit.events.models import BaseEvent
22
+ from corekit.observability.benchmarkable import Benchmarkable
23
+
24
+ __all__ = ["EventPublisher"]
25
+
26
+
27
+ class EventPublisher(Benchmarkable):
28
+ """
29
+ Publishes events to one Redis channel.
30
+
31
+ Publishing never raises: an event that cannot be delivered should not take
32
+ down the operation that produced it. ``publish`` returns whether it worked.
33
+ """
34
+
35
+ def __init__(self, channel: str) -> None:
36
+ super().__init__()
37
+ self.channel = channel
38
+
39
+ @classmethod
40
+ def for_resource(cls, service: str, resource_type: str, resource_id: str) -> "EventPublisher":
41
+ """
42
+ Build a publisher for the conventional ``service:type:id`` channel.
43
+ """
44
+ return cls(f"{service}:{resource_type}:{resource_id}")
45
+
46
+ def publish(self, event_type: str, data: dict[str, Any], **kwargs: Any) -> bool:
47
+ """
48
+ Publish an event, returning whether it was delivered.
49
+ """
50
+ return self.publish_event(BaseEvent(type=event_type, data=data, **kwargs))
51
+
52
+ @connect(RedisConnection)
53
+ def publish_event(self, conn: RedisConnection, event: BaseEvent) -> bool:
54
+ """
55
+ Publish an already-built event.
56
+ """
57
+ try:
58
+ subscribers = conn.client.publish(self.channel, event.model_dump_json())
59
+ self.debug(f"Published {event.type!r} to {self.channel} ({subscribers} subscriber(s))")
60
+ return True
61
+ except Exception as exc:
62
+ self.error(f"Failed to publish to {self.channel}: {exc}")
63
+ return False
64
+
65
+ @connect(RedisConnection)
66
+ def subscriber_count(self, conn: RedisConnection) -> int:
67
+ """
68
+ How many subscribers this channel currently has.
69
+ """
70
+ try:
71
+ result = conn.client.pubsub_numsub(self.channel)
72
+ return result[0][1] if result else 0
73
+ except Exception as exc:
74
+ self.error(f"Failed to count subscribers for {self.channel}: {exc}")
75
+ return 0
@@ -0,0 +1,132 @@
1
+ """
2
+ Reading a Redis Pub/Sub channel.
3
+
4
+ Holds what every streaming shape needs -- opening an async connection,
5
+ subscribing, decoding payloads, and unsubscribing even when the consumer
6
+ disappears -- so no consumer has to repeat it.
7
+
8
+ Everything here uses Redis's async client. The synchronous client's
9
+ ``get_message`` blocks the thread it is called on, which in an async server is
10
+ the event loop, stalling every other request in the process while one client
11
+ waits for an event that may never come.
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ from typing import Any
17
+
18
+ from corekit.connections.redis import RedisConnection
19
+ from corekit.events.enum import PubSubField, PubSubMessageType
20
+ from corekit.observability.loggable import Loggable
21
+
22
+ __all__ = ["RedisChannelReader"]
23
+
24
+ # How long to yield to the event loop when the socket has nothing ready.
25
+ IDLE_SLEEP_SECONDS = 0.01
26
+
27
+
28
+ class RedisChannelReader(Loggable):
29
+ """
30
+ Subscribes to a Redis channel and yields decoded messages.
31
+
32
+ Holds the parts that both streaming shapes need -- opening an async
33
+ connection, subscribing, decoding payloads, and unsubscribing even when the
34
+ consumer disappears -- so neither has to repeat them.
35
+ """
36
+
37
+ def __init__(self, channel: str, url: str | None = None) -> None:
38
+ super().__init__()
39
+ self.channel = channel
40
+ self._url = url
41
+ self._connection: RedisConnection | None = None
42
+ self._pubsub: Any = None
43
+
44
+ @property
45
+ def is_open(self) -> bool:
46
+ """
47
+ Whether the subscription is currently established.
48
+ """
49
+ return self._pubsub is not None
50
+
51
+ async def open(self) -> bool:
52
+ """
53
+ Connect and subscribe, reporting whether it worked.
54
+
55
+ Returns False rather than raising when Redis is unreachable: a stream
56
+ that cannot start should tell its client, not crash the request.
57
+ """
58
+ connection = RedisConnection(url=self._url, safe=True)
59
+ await connection.async_connect()
60
+
61
+ if not connection.is_async_connected:
62
+ self.error(f"Redis unavailable; cannot subscribe to {self.channel}")
63
+ return False
64
+
65
+ self._connection = connection
66
+ self._pubsub = connection.async_client.pubsub()
67
+ await self._pubsub.subscribe(self.channel)
68
+ self.info(f"Subscribed to {self.channel}")
69
+ return True
70
+
71
+ async def close(self) -> None:
72
+ """
73
+ Unsubscribe and disconnect. Safe to call more than once.
74
+ """
75
+ if self._pubsub is not None:
76
+ try:
77
+ await self._pubsub.unsubscribe(self.channel)
78
+ await self._pubsub.aclose()
79
+ except Exception as exc:
80
+ self.debug(f"Error closing subscription to {self.channel}: {exc}")
81
+ self._pubsub = None
82
+
83
+ if self._connection is not None:
84
+ await self._connection.async_disconnect()
85
+ self._connection = None
86
+
87
+ async def next_payload(self, timeout: float | None) -> Any:
88
+ """
89
+ Return the next decoded payload.
90
+
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.
94
+ """
95
+ try:
96
+ message = await asyncio.wait_for(
97
+ self._pubsub.get_message(ignore_subscribe_messages=True),
98
+ timeout=timeout,
99
+ )
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
107
+
108
+ if message.get(PubSubField.TYPE.value) not in (None, PubSubMessageType.MESSAGE.value):
109
+ return None
110
+
111
+ return self._decode(message.get(PubSubField.DATA.value))
112
+
113
+ def _decode(self, raw: Any) -> Any:
114
+ """
115
+ Decode a payload, returning the raw text when it is not JSON.
116
+
117
+ A shared channel may have writers that are not EventPublisher, and one
118
+ of those should not look like a fault.
119
+ """
120
+ if isinstance(raw, bytes):
121
+ raw = raw.decode("utf-8", errors="replace")
122
+ try:
123
+ return json.loads(raw)
124
+ except (TypeError, ValueError):
125
+ return raw
126
+
127
+ async def __aenter__(self) -> "RedisChannelReader":
128
+ await self.open()
129
+ return self
130
+
131
+ async def __aexit__(self, *exc_info: Any) -> None:
132
+ await self.close()
corekit/events/sse.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ Streaming events to a browser over Server-Sent Events.
3
+
4
+ SSE is the simpler half of real-time: a plain HTTP response the browser
5
+ reconnects on its own, with no protocol upgrade.
6
+
7
+ @router.get("/events")
8
+ async def events(channel: str) -> SSEResponse:
9
+ return SSEResponse(SSEStream(channel))
10
+
11
+ The browser side is three lines::
12
+
13
+ const source = new EventSource("/events?channel=backups:events");
14
+ source.addEventListener("finished", e => console.log(JSON.parse(e.data)));
15
+ """
16
+
17
+ import asyncio
18
+ from typing import Any, AsyncIterator
19
+
20
+ from corekit.events.enum import MessageField, StreamEvent
21
+ from corekit.events.frames import SSEFrame
22
+ from corekit.events.reader import RedisChannelReader
23
+ from corekit.observability.loggable import Loggable
24
+
25
+ __all__ = ["SSEStream"]
26
+
27
+ DEFAULT_KEEPALIVE_SECONDS = 30.0
28
+
29
+
30
+ class SSEStream(Loggable):
31
+ """
32
+ An async iterator of SSE frames for one Redis channel.
33
+
34
+ return SSEResponse(SSEStream(channel, initial_state={"running": 2}))
35
+
36
+ Emits a ``connected`` frame on subscribe, an optional ``initial_state`` so a
37
+ client arriving late renders immediately, then one frame per published
38
+ event, with a comment frame whenever the channel is quiet.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ channel: str,
44
+ initial_state: dict[str, Any] | None = None,
45
+ keepalive_interval: float | None = DEFAULT_KEEPALIVE_SECONDS,
46
+ url: str | None = None,
47
+ ) -> None:
48
+ """
49
+ :param channel: the Redis channel to subscribe to.
50
+ :param initial_state: sent once, immediately after connecting.
51
+ :param keepalive_interval: seconds between comment frames; None disables them.
52
+ :param url: Redis URL, defaulting to the configured one.
53
+ """
54
+ super().__init__()
55
+ self.channel = channel
56
+ self.initial_state = initial_state
57
+ self.keepalive_interval = keepalive_interval
58
+ self._reader = RedisChannelReader(channel, url=url)
59
+
60
+ def _opening_frames(self) -> list[str]:
61
+ """
62
+ The frames sent before any published event.
63
+ """
64
+ frames = [SSEFrame.event(StreamEvent.CONNECTED, {MessageField.CHANNEL.value: self.channel})]
65
+ if self.initial_state is not None:
66
+ frames.append(SSEFrame.event(StreamEvent.INITIAL_STATE, self.initial_state))
67
+ return frames
68
+
69
+ @staticmethod
70
+ def _frame_for(payload: Any) -> str:
71
+ """
72
+ Render one decoded payload as a frame.
73
+
74
+ A dict carrying a ``type`` is an event and keeps its name; anything else
75
+ is passed through under a generic name rather than being dropped.
76
+ """
77
+ if isinstance(payload, dict) and MessageField.TYPE.value in payload:
78
+ return SSEFrame.event(payload[MessageField.TYPE.value], payload)
79
+ return SSEFrame.event(StreamEvent.MESSAGE, payload)
80
+
81
+ async def __aiter__(self) -> AsyncIterator[str]:
82
+ """
83
+ Yield frames until the client disconnects.
84
+ """
85
+ if not await self._reader.open():
86
+ yield SSEFrame.error("Event stream unavailable")
87
+ return
88
+
89
+ try:
90
+ for frame in self._opening_frames():
91
+ yield frame
92
+
93
+ while True:
94
+ payload = await self._reader.next_payload(timeout=self.keepalive_interval)
95
+ if payload is None:
96
+ yield SSEFrame.comment()
97
+ continue
98
+ yield self._frame_for(payload)
99
+
100
+ except asyncio.CancelledError:
101
+ self.info(f"Stream for {self.channel} cancelled")
102
+ raise
103
+ except GeneratorExit:
104
+ self.info(f"Client disconnected from {self.channel}")
105
+ except Exception as exc:
106
+ self.error(f"Error streaming {self.channel}: {exc}")
107
+ yield SSEFrame.error(str(exc))
108
+ finally:
109
+ await self._reader.close()
@@ -0,0 +1,97 @@
1
+ """
2
+ Relaying events to a WebSocket.
3
+
4
+ await WebSocketBridge(channel, websocket, terminal_statuses=["done"]).run()
5
+
6
+ Use this rather than SSE when the client also needs to send messages back; for
7
+ one-way updates SSE is less machinery and reconnects on its own.
8
+ """
9
+
10
+ import asyncio
11
+ from typing import Any
12
+
13
+ from corekit.events.enum import MessageField
14
+ from corekit.events.reader import RedisChannelReader
15
+ from corekit.observability.loggable import Loggable
16
+
17
+ __all__ = ["WebSocketBridge"]
18
+
19
+ DEFAULT_BRIDGE_TIMEOUT_SECONDS = 300.0
20
+
21
+ # Event type carrying a lifecycle status, checked against terminal_statuses.
22
+ STATUS_UPDATE_EVENT = "status_update"
23
+
24
+
25
+ class WebSocketBridge(Loggable):
26
+ """
27
+ Relays everything published to a channel to an open WebSocket.
28
+
29
+ await WebSocketBridge(channel, websocket, terminal_statuses=["done"]).run()
30
+
31
+ Stops on a terminal status so a client watching a job disconnects when the
32
+ job ends, and on a timeout so an abandoned socket cannot hold a subscription
33
+ open forever.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ channel: str,
39
+ websocket: Any,
40
+ terminal_statuses: list[str] | None = None,
41
+ timeout: float | None = DEFAULT_BRIDGE_TIMEOUT_SECONDS,
42
+ url: str | None = None,
43
+ ) -> None:
44
+ super().__init__()
45
+ self.channel = channel
46
+ self.websocket = websocket
47
+ self.terminal_statuses = terminal_statuses or []
48
+ self.timeout = timeout
49
+ self._reader = RedisChannelReader(channel, url=url)
50
+
51
+ def _is_terminal(self, payload: Any) -> bool:
52
+ """
53
+ Whether this payload means the thing being watched has finished.
54
+ """
55
+ if not self.terminal_statuses or not isinstance(payload, dict):
56
+ return False
57
+ if payload.get(MessageField.TYPE.value) != STATUS_UPDATE_EVENT:
58
+ return False
59
+ data = payload.get(MessageField.DATA.value) or {}
60
+ return data.get(MessageField.STATUS.value) in self.terminal_statuses
61
+
62
+ def _expired(self, started: float) -> bool:
63
+ """
64
+ Whether the bridge has outlived its timeout.
65
+ """
66
+ if self.timeout is None:
67
+ return False
68
+ return asyncio.get_event_loop().time() - started > self.timeout
69
+
70
+ async def run(self) -> None:
71
+ """
72
+ Relay until a terminal status, the timeout, or an error.
73
+ """
74
+ if not await self._reader.open():
75
+ return
76
+
77
+ started = asyncio.get_event_loop().time()
78
+ try:
79
+ 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
87
+
88
+ if self._expired(started):
89
+ self.warning(f"Bridge on {self.channel} timed out")
90
+ break
91
+
92
+ except asyncio.CancelledError:
93
+ raise
94
+ except Exception as exc:
95
+ self.error(f"Error bridging {self.channel}: {exc}")
96
+ finally:
97
+ await self._reader.close()
File without changes
@@ -0,0 +1,45 @@
1
+ """
2
+ Base exception types.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import HTTPException
8
+
9
+ __all__ = ["CustomException", "CustomHTTPException", "ExponentialBackoffTimeoutException"]
10
+
11
+
12
+ class CustomException(Exception):
13
+ """
14
+ Base for corekit exceptions, carrying optional error context.
15
+ """
16
+
17
+ def __init__(self, message: str, error: str | None = None) -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+ self.error = error
21
+
22
+
23
+ class ExponentialBackoffTimeoutException(CustomException):
24
+ """
25
+ Raised when a retry loop exhausts its attempts without succeeding.
26
+ """
27
+
28
+ def __init__(self, attempts: int, error: str | None = None) -> None:
29
+ super().__init__(
30
+ message=f"Exponential Backoff timed out after {attempts} attempts",
31
+ error=error,
32
+ )
33
+ self.attempts = attempts
34
+
35
+
36
+ class CustomHTTPException(HTTPException):
37
+ """
38
+ FastAPI HTTPException that also accepts ``message=`` as an alias for ``detail=``.
39
+ """
40
+
41
+ def __init__(self, **kwargs: Any) -> None:
42
+ message = kwargs.pop("message", None)
43
+ if message:
44
+ kwargs["detail"] = message
45
+ super().__init__(**kwargs)
File without changes
File without changes