python-corekit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- corekit/__init__.py +0 -0
- corekit/api/__init__.py +9 -0
- corekit/api/handler.py +76 -0
- corekit/api/responses.py +40 -0
- corekit/api/routers.py +115 -0
- corekit/concurrency/__init__.py +9 -0
- corekit/concurrency/decorators.py +72 -0
- corekit/concurrency/thread_local.py +99 -0
- corekit/concurrency/worker.py +65 -0
- corekit/config/__init__.py +47 -0
- corekit/config/loader.py +153 -0
- corekit/config/settings.py +161 -0
- corekit/config/sources.py +125 -0
- corekit/connections/__init__.py +31 -0
- corekit/connections/connectable.py +212 -0
- corekit/connections/decorators.py +92 -0
- corekit/connections/redis/__init__.py +7 -0
- corekit/connections/redis/connection.py +239 -0
- corekit/connections/registry.py +80 -0
- corekit/connections/sql/__init__.py +10 -0
- corekit/connections/sql/connection.py +342 -0
- corekit/connections/sql/fields/__init__.py +7 -0
- corekit/connections/sql/fields/jsonb.py +67 -0
- corekit/connections/sql/migration/__init__.py +57 -0
- corekit/connections/sql/migration/base.py +40 -0
- corekit/connections/sql/migration/operations.py +416 -0
- corekit/connections/sql/migration/registry.py +166 -0
- corekit/connections/sql/migration/table.py +27 -0
- corekit/connections/sql/query.py +68 -0
- corekit/connections/sql/table.py +96 -0
- corekit/constants.py +45 -0
- corekit/crypto/__init__.py +1 -0
- corekit/crypto/constants.py +7 -0
- corekit/crypto/enum.py +11 -0
- corekit/crypto/hasher.py +89 -0
- corekit/data/__init__.py +81 -0
- corekit/data/dataset.py +340 -0
- corekit/data/expressions/__init__.py +46 -0
- corekit/data/expressions/comparison.py +252 -0
- corekit/data/expressions/expression.py +98 -0
- corekit/data/record.py +147 -0
- corekit/data/stats.py +157 -0
- corekit/decorators/__init__.py +2 -0
- corekit/decorators/exception_handling.py +43 -0
- corekit/decorators/warnings.py +35 -0
- corekit/docker/__init__.py +7 -0
- corekit/docker/watchdog.py +222 -0
- corekit/etl/__init__.py +44 -0
- corekit/etl/connection.py +44 -0
- corekit/etl/extract/__init__.py +0 -0
- corekit/etl/extract/extractor.py +48 -0
- corekit/etl/extract/schemas.py +18 -0
- corekit/etl/load/__init__.py +0 -0
- corekit/etl/load/loader.py +53 -0
- corekit/etl/load/schemas.py +33 -0
- corekit/etl/orchestrator.py +201 -0
- corekit/etl/schemas.py +22 -0
- corekit/etl/transform/__init__.py +0 -0
- corekit/etl/transform/schemas.py +15 -0
- corekit/etl/transform/transformer.py +28 -0
- corekit/events/__init__.py +38 -0
- corekit/events/enum.py +58 -0
- corekit/events/frames.py +51 -0
- corekit/events/models.py +23 -0
- corekit/events/publisher.py +75 -0
- corekit/events/reader.py +132 -0
- corekit/events/sse.py +109 -0
- corekit/events/websocket.py +97 -0
- corekit/exceptions/__init__.py +0 -0
- corekit/exceptions/base.py +45 -0
- corekit/exceptions/custom/__init__.py +0 -0
- corekit/exceptions/http/__init__.py +0 -0
- corekit/exceptions/http/exceptions.py +37 -0
- corekit/exceptions/types.py +17 -0
- corekit/files/__init__.py +25 -0
- corekit/files/base.py +117 -0
- corekit/files/enum.py +30 -0
- corekit/files/json.py +12 -0
- corekit/files/pickle.py +12 -0
- corekit/files/toml.py +43 -0
- corekit/http/__init__.py +0 -0
- corekit/http/client.py +176 -0
- corekit/http/exponential_backoff.py +100 -0
- corekit/http/response.py +12 -0
- corekit/log_monitor/__init__.py +23 -0
- corekit/log_monitor/constants.py +8 -0
- corekit/log_monitor/models.py +150 -0
- corekit/log_monitor/service.py +418 -0
- corekit/notifications/__init__.py +8 -0
- corekit/notifications/base.py +51 -0
- corekit/notifications/models.py +34 -0
- corekit/observability/__init__.py +21 -0
- corekit/observability/benchmarkable.py +12 -0
- corekit/observability/loggable.py +29 -0
- corekit/observability/timing/__init__.py +0 -0
- corekit/observability/timing/constants.py +1 -0
- corekit/observability/timing/split.py +20 -0
- corekit/observability/timing/timer.py +30 -0
- corekit/py.typed +0 -0
- corekit/registry/__init__.py +12 -0
- corekit/registry/registry.py +134 -0
- corekit/schemas/__init__.py +0 -0
- corekit/schemas/dataclasses/__init__.py +0 -0
- corekit/schemas/enum.py +49 -0
- corekit/schemas/models/__init__.py +0 -0
- corekit/schemas/models/arbitrary.py +11 -0
- corekit/schemas/models/date_models.py +18 -0
- corekit/schemas/pydantic/__init__.py +0 -0
- corekit/schemas/pydantic/fields.py +35 -0
- corekit/schemas/types.py +40 -0
- corekit/serialization/__init__.py +0 -0
- corekit/serialization/enum.py +21 -0
- corekit/serialization/serializable.py +42 -0
- corekit/serialization/serializer.py +179 -0
- corekit/utils/__init__.py +5 -0
- corekit/utils/ids.py +5 -0
- corekit/utils/raise_exc.py +8 -0
- corekit/utils/time.py +21 -0
- corekit/utils/validators.py +15 -0
- corekit/utils/void.py +8 -0
- python_corekit-0.1.0.dist-info/METADATA +417 -0
- python_corekit-0.1.0.dist-info/RECORD +125 -0
- python_corekit-0.1.0.dist-info/WHEEL +5 -0
- python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_corekit-0.1.0.dist-info/top_level.txt +1 -0
|
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"
|
corekit/events/frames.py
ADDED
|
@@ -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}"
|
corekit/events/models.py
ADDED
|
@@ -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
|
corekit/events/reader.py
ADDED
|
@@ -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
|