mgf-livepush 0.1.2__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.
@@ -0,0 +1,36 @@
1
+ """mgf-livepush — realtime push pipeline (SSE + pluggable pub/sub broker).
2
+
3
+ A `mgf-common` sibling under the `mgf.*` namespace. Public surface:
4
+
5
+ * :class:`SSEStreamService` / :class:`SSEStream` / :func:`sse_headers`
6
+ — the broker-agnostic SSE streaming primitive.
7
+ * :class:`Broker` / :class:`RedisBroker` / :class:`MockBroker` /
8
+ :class:`Subscription` — the pub/sub seam + adapters.
9
+ * :class:`SlotCap` — the per-key connection-slot cap (R-28 pattern).
10
+ * :class:`CapExceededError` / :class:`LivePushError` — typed errors
11
+ (subclass ``mgf.common.exceptions.AppError``).
12
+
13
+ The FastAPI ``StreamingResponse`` wrapper lives in
14
+ :mod:`mgf.livepush.fastapi` (requires the ``[fastapi]`` extra). See the
15
+ README for the dev/prod-proxy SSE recipe.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from mgf.livepush._broker import Broker, MockBroker, RedisBroker, Subscription
21
+ from mgf.livepush._errors import CapExceededError, LivePushError
22
+ from mgf.livepush._slotcap import SlotCap
23
+ from mgf.livepush._sse import SSEStream, SSEStreamService, sse_headers
24
+
25
+ __all__ = [
26
+ "Broker",
27
+ "CapExceededError",
28
+ "LivePushError",
29
+ "MockBroker",
30
+ "RedisBroker",
31
+ "SSEStream",
32
+ "SSEStreamService",
33
+ "SlotCap",
34
+ "Subscription",
35
+ "sse_headers",
36
+ ]
@@ -0,0 +1,129 @@
1
+ """Pluggable pub/sub broker seam for live-push fan-out.
2
+
3
+ `Broker` is the port; `RedisBroker` (gated behind the `[redis]` extra)
4
+ is the production adapter and `MockBroker` is the in-process test
5
+ double. A consumer that wants Redis Streams / NATS / Kafka writes a new
6
+ adapter — `SSEStreamService` never sees the broker's internals.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+ __all__ = ["Broker", "MockBroker", "RedisBroker", "Subscription"]
16
+
17
+
18
+ @runtime_checkable
19
+ class Subscription(Protocol):
20
+ """A live subscription to one channel."""
21
+
22
+ async def get_message(self, *, timeout: float) -> bytes | None:
23
+ """Return the next message payload, or ``None`` on timeout."""
24
+ ...
25
+
26
+ async def close(self) -> None:
27
+ """Release the subscription (unsubscribe + free resources)."""
28
+ ...
29
+
30
+
31
+ @runtime_checkable
32
+ class Broker(Protocol):
33
+ """A pub/sub backend behind a two-method port."""
34
+
35
+ async def publish(self, channel: str, payload: str | bytes) -> int:
36
+ """Publish ``payload`` to ``channel``; return the delivery count."""
37
+ ...
38
+
39
+ async def subscribe(self, channel: str) -> Subscription:
40
+ """Open a :class:`Subscription` to ``channel``."""
41
+ ...
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Redis adapter (requires the `[redis]` extra)
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class _RedisSubscription:
50
+ def __init__(self, pubsub: Any, channel: str) -> None:
51
+ self._pubsub = pubsub
52
+ self._channel = channel
53
+
54
+ async def get_message(self, *, timeout: float) -> bytes | None:
55
+ msg = await self._pubsub.get_message(
56
+ ignore_subscribe_messages=True, timeout=timeout
57
+ )
58
+ if msg is None:
59
+ return None
60
+ data = msg.get("data")
61
+ if isinstance(data, bytes):
62
+ return data
63
+ if isinstance(data, str):
64
+ return data.encode("utf-8")
65
+ return None
66
+
67
+ async def close(self) -> None:
68
+ with contextlib.suppress(Exception):
69
+ await self._pubsub.unsubscribe(self._channel)
70
+ await self._pubsub.aclose()
71
+
72
+
73
+ class RedisBroker:
74
+ """Redis pub/sub adapter. Pass a ``redis.asyncio.Redis`` client."""
75
+
76
+ def __init__(self, redis: Any) -> None:
77
+ self._redis = redis
78
+
79
+ async def publish(self, channel: str, payload: str | bytes) -> int:
80
+ return int(await self._redis.publish(channel, payload))
81
+
82
+ async def subscribe(self, channel: str) -> Subscription:
83
+ pubsub = self._redis.pubsub()
84
+ await pubsub.subscribe(channel)
85
+ return _RedisSubscription(pubsub, channel)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # In-process mock adapter (for consumers' tests)
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ class _MockSubscription:
94
+ def __init__(self) -> None:
95
+ self._queue: asyncio.Queue[bytes] = asyncio.Queue()
96
+ self._closed = False
97
+
98
+ async def get_message(self, *, timeout: float) -> bytes | None:
99
+ try:
100
+ return await asyncio.wait_for(self._queue.get(), timeout)
101
+ except TimeoutError:
102
+ return None
103
+
104
+ async def close(self) -> None:
105
+ self._closed = True
106
+
107
+ def deliver(self, data: bytes) -> None:
108
+ if not self._closed:
109
+ self._queue.put_nowait(data)
110
+
111
+
112
+ class MockBroker:
113
+ """Deterministic in-process broker. Only delivers to subscribers that
114
+ were subscribed BEFORE the publish (matching real pub/sub semantics)."""
115
+
116
+ def __init__(self) -> None:
117
+ self._subs: dict[str, list[_MockSubscription]] = {}
118
+
119
+ async def publish(self, channel: str, payload: str | bytes) -> int:
120
+ data = payload.encode("utf-8") if isinstance(payload, str) else payload
121
+ subs = self._subs.get(channel, [])
122
+ for sub in subs:
123
+ sub.deliver(data)
124
+ return len(subs)
125
+
126
+ async def subscribe(self, channel: str) -> Subscription:
127
+ sub = _MockSubscription()
128
+ self._subs.setdefault(channel, []).append(sub)
129
+ return sub
@@ -0,0 +1,31 @@
1
+ """mgf-livepush typed errors.
2
+
3
+ All subclass ``mgf.common.exceptions.AppError`` so a consumer's
4
+ top-level handler treats them uniformly (rule EH-01).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from mgf.common.exceptions import AppError
10
+
11
+ __all__ = ["CapExceededError", "LivePushError"]
12
+
13
+
14
+ class LivePushError(AppError):
15
+ """Base class for every mgf-livepush error."""
16
+
17
+
18
+ class CapExceededError(LivePushError):
19
+ """The per-key connection-slot cap is exhausted.
20
+
21
+ The caller should refuse the new stream (HTTP 429). Carries the
22
+ ``slot_key`` + ``cap`` for logging / the ``Retry-After`` response.
23
+ """
24
+
25
+ def __init__(self, slot_key: str, cap: int) -> None:
26
+ self.slot_key = slot_key
27
+ self.cap = cap
28
+ super().__init__(
29
+ f"live-push slot cap of {cap} concurrent connections "
30
+ f"exceeded for {slot_key!r}"
31
+ )
@@ -0,0 +1,92 @@
1
+ """Per-key connection-slot cap over a Redis counter (the R-28 pattern).
2
+
3
+ INCR on acquire, DECR on release, with a TTL so a crashed process can't
4
+ leak a slot forever. Best-effort + **fail-OPEN**: a Redis outage lets
5
+ connections through rather than locking everyone out (the push path is
6
+ already degraded in an outage; denying authenticated users on top is the
7
+ wrong failure mode). Optional ``on_acquire`` / ``on_release`` callbacks
8
+ let a consumer wire its own observability without this module importing
9
+ it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ __all__ = ["SlotCap"]
21
+
22
+ LOG = logging.getLogger(__name__)
23
+
24
+
25
+ class SlotCap:
26
+ def __init__(
27
+ self,
28
+ redis: Any,
29
+ *,
30
+ cap: int = 20,
31
+ key_prefix: str = "livepush:slot:",
32
+ ttl_seconds: int = 300,
33
+ on_acquire: Callable[[str], None] | None = None,
34
+ on_release: Callable[[str], None] | None = None,
35
+ ) -> None:
36
+ self._redis = redis
37
+ self.cap = cap
38
+ self._prefix = key_prefix
39
+ self._ttl = ttl_seconds
40
+ self._on_acquire = on_acquire
41
+ self._on_release = on_release
42
+
43
+ def _key(self, slot_key: str) -> str:
44
+ return f"{self._prefix}{slot_key}"
45
+
46
+ async def acquire(self, slot_key: str) -> bool:
47
+ """Reserve one slot for ``slot_key``. Returns ``False`` if the cap
48
+ is reached (caller refuses); ``True`` otherwise (caller MUST
49
+ eventually :meth:`release`). Fails OPEN (returns ``True``) on any
50
+ Redis error."""
51
+ key = self._key(slot_key)
52
+ try:
53
+ count = await self._redis.incr(key)
54
+ if count == 1:
55
+ await self._redis.expire(key, self._ttl)
56
+ if count > self.cap:
57
+ await self._redis.decr(key)
58
+ return False
59
+ except Exception:
60
+ LOG.warning(
61
+ "livepush: slot acquire failed; failing open",
62
+ exc_info=True,
63
+ extra={
64
+ "slot_key": slot_key,
65
+ "cap": self.cap,
66
+ "fingerprint": f"livepush.slot_acquire_failed:{slot_key}",
67
+ },
68
+ )
69
+ return True
70
+ if self._on_acquire is not None:
71
+ self._on_acquire(slot_key)
72
+ return True
73
+
74
+ async def release(self, slot_key: str) -> None:
75
+ """Release one slot for ``slot_key``. No-op on Redis failure;
76
+ floors the counter at 0."""
77
+ key = self._key(slot_key)
78
+ try:
79
+ count = await self._redis.decr(key)
80
+ if count is not None and count < 0:
81
+ await self._redis.set(key, 0, ex=self._ttl)
82
+ except Exception:
83
+ LOG.warning(
84
+ "livepush: slot release failed (best-effort)",
85
+ exc_info=True,
86
+ extra={
87
+ "slot_key": slot_key,
88
+ "fingerprint": f"livepush.slot_release_failed:{slot_key}",
89
+ },
90
+ )
91
+ if self._on_release is not None:
92
+ self._on_release(slot_key)
mgf/livepush/_sse.py ADDED
@@ -0,0 +1,117 @@
1
+ """Broker-agnostic SSE streaming primitive.
2
+
3
+ `SSEStreamService.open_stream` acquires a slot (raising
4
+ :class:`CapExceededError` *synchronously* if the cap is full — so a
5
+ consumer maps it to 429 BEFORE opening the stream), subscribes via the
6
+ broker, and returns an :class:`SSEStream` (headers + an async byte
7
+ iterator). The iterator emits the ``retry:`` directive, heartbeat
8
+ comments, and ``data:`` frames, and in a ``finally`` block releases the
9
+ slot + closes the subscription on disconnect / cancel / error.
10
+
11
+ Framework-agnostic: the consumer supplies an async ``is_disconnected``
12
+ callable (FastAPI's ``request.is_disconnected``) and wraps the iterator
13
+ in whatever streaming response its framework uses.
14
+ :mod:`mgf.livepush.fastapi` ships a one-line wrapper.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ import logging
22
+ from dataclasses import dataclass
23
+ from typing import TYPE_CHECKING
24
+
25
+ from mgf.livepush._errors import CapExceededError
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import AsyncIterator, Awaitable, Callable
29
+
30
+ from mgf.livepush._broker import Broker, Subscription
31
+ from mgf.livepush._slotcap import SlotCap
32
+
33
+ __all__ = ["SSEStream", "SSEStreamService", "sse_headers"]
34
+
35
+ LOG = logging.getLogger(__name__)
36
+
37
+ # EventSource reconnect cadence (ms). Pairs with a 429 Retry-After: 5 so
38
+ # a flapping client doesn't tighten into a hot loop against the cap.
39
+ _RETRY_MS = 5000
40
+ # Heartbeat envelope — survives AWS ALB / Cloudflare 30 s idle-close.
41
+ _DEFAULT_HEARTBEAT_SECONDS = 15.0
42
+
43
+
44
+ def sse_headers() -> dict[str, str]:
45
+ """Headers every SSE response needs so proxies pass the wire
46
+ untouched (disable caching/transform + Nginx-style buffering)."""
47
+ return {"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}
48
+
49
+
50
+ @dataclass
51
+ class SSEStream:
52
+ """The result of :meth:`SSEStreamService.open_stream` — the headers to
53
+ set on the response + the byte iterator to stream as its body."""
54
+
55
+ headers: dict[str, str]
56
+ body: AsyncIterator[bytes]
57
+
58
+
59
+ class SSEStreamService:
60
+ """Per-app SSE primitive over a :class:`Broker` + :class:`SlotCap`."""
61
+
62
+ def __init__(
63
+ self,
64
+ broker: Broker,
65
+ slot_cap: SlotCap,
66
+ *,
67
+ heartbeat_seconds: float = _DEFAULT_HEARTBEAT_SECONDS,
68
+ ) -> None:
69
+ self._broker = broker
70
+ self._slot_cap = slot_cap
71
+ self._heartbeat_s = heartbeat_seconds
72
+
73
+ async def open_stream(
74
+ self,
75
+ *,
76
+ channel: str,
77
+ slot_key: str,
78
+ is_disconnected: Callable[[], Awaitable[bool]],
79
+ ) -> SSEStream:
80
+ """Acquire a slot, subscribe to ``channel``, return an
81
+ :class:`SSEStream`. Raises :class:`CapExceededError` synchronously
82
+ when the cap is full — no subscription / slot leak (the acquire
83
+ happens before any pubsub state)."""
84
+ acquired = await self._slot_cap.acquire(slot_key)
85
+ if not acquired:
86
+ raise CapExceededError(slot_key, self._slot_cap.cap)
87
+ sub = await self._broker.subscribe(channel)
88
+ return SSEStream(
89
+ headers=sse_headers(),
90
+ body=self._event_gen(sub, slot_key, is_disconnected),
91
+ )
92
+
93
+ async def _event_gen(
94
+ self,
95
+ sub: Subscription,
96
+ slot_key: str,
97
+ is_disconnected: Callable[[], Awaitable[bool]],
98
+ ) -> AsyncIterator[bytes]:
99
+ try:
100
+ yield f"retry: {_RETRY_MS}\n\n".encode()
101
+ while True:
102
+ data = await sub.get_message(timeout=self._heartbeat_s)
103
+ if await is_disconnected():
104
+ return
105
+ if data is None:
106
+ yield b": keep-alive\n\n"
107
+ continue
108
+ yield b"data: " + data + b"\n\n"
109
+ except asyncio.CancelledError:
110
+ return
111
+ finally:
112
+ # Close the subscription BEFORE releasing the slot: if release
113
+ # came first and close hung, the slot would be reusable before
114
+ # the underlying resource was freed (slot-leak adjacent).
115
+ with contextlib.suppress(Exception):
116
+ await sub.close()
117
+ await self._slot_cap.release(slot_key)
@@ -0,0 +1,50 @@
1
+ """Optional FastAPI adapter (requires the ``[fastapi]`` extra).
2
+
3
+ One line in a route handler turns an :class:`SSEStreamService` into a
4
+ ``StreamingResponse``, mapping the cap-exceeded case to a 429 with a
5
+ ``Retry-After`` header.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from mgf.livepush._errors import CapExceededError
13
+
14
+ if TYPE_CHECKING:
15
+ from fastapi import Request
16
+ from fastapi.responses import StreamingResponse
17
+
18
+ from mgf.livepush._sse import SSEStreamService
19
+
20
+ __all__ = ["sse_streaming_response"]
21
+
22
+
23
+ async def sse_streaming_response(
24
+ service: SSEStreamService,
25
+ request: Request,
26
+ *,
27
+ channel: str,
28
+ slot_key: str,
29
+ ) -> StreamingResponse:
30
+ """Open an SSE stream and wrap it in a FastAPI ``StreamingResponse``.
31
+
32
+ Raises ``HTTPException(429)`` with ``Retry-After: 5`` when the
33
+ per-key slot cap is exhausted.
34
+ """
35
+ from fastapi import HTTPException
36
+ from fastapi.responses import StreamingResponse
37
+
38
+ try:
39
+ stream = await service.open_stream(
40
+ channel=channel,
41
+ slot_key=slot_key,
42
+ is_disconnected=request.is_disconnected,
43
+ )
44
+ except CapExceededError as exc:
45
+ raise HTTPException(
46
+ status_code=429, detail=str(exc), headers={"Retry-After": "5"}
47
+ ) from exc
48
+ return StreamingResponse(
49
+ stream.body, media_type="text/event-stream", headers=stream.headers
50
+ )
mgf/livepush/py.typed ADDED
File without changes
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: mgf-livepush
3
+ Version: 0.1.2
4
+ Summary: Realtime push pipeline for mgf-common consumers — SSE streaming, a pluggable pub/sub broker seam (Redis + mock), and per-key connection-slot caps. Sibling of mgf-common under the mgf.* namespace.
5
+ Author: Bassam Alsanie, mgf-livepush contributors
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: fastapi,pubsub,realtime,redis,sse,streaming
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: mgf-common<0.43,>=0.41
20
+ Provides-Extra: dev
21
+ Requires-Dist: import-linter>=2.0; extra == 'dev'
22
+ Requires-Dist: mgf-test-supervisor<0.2,>=0.1.2; extra == 'dev'
23
+ Requires-Dist: mypy>=1.10; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
26
+ Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Provides-Extra: fastapi
30
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
31
+ Provides-Extra: redis
32
+ Requires-Dist: redis>=5.0; extra == 'redis'
33
+ Provides-Extra: test
34
+ Requires-Dist: fastapi>=0.110; extra == 'test'
35
+ Requires-Dist: redis>=5.0; extra == 'test'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # mgf-livepush
39
+
40
+ Realtime push pipeline for `mgf-common` consumers: an SSE streaming
41
+ primitive, a pluggable pub/sub **broker seam** (Redis adapter + an
42
+ in-process mock), and a per-key connection-**slot cap**. A sibling of
43
+ `mgf-common` under the `mgf.*` namespace.
44
+
45
+ Extracted from PlasmaMapper's Phase-4 push surface after the
46
+ pre-release core re-design hardened it (heartbeat cadence, slot
47
+ lifecycle, disconnect cleanup, and the dev/prod-proxy buffering recipe
48
+ below). The broker seam means a consumer can swap Redis pub/sub for
49
+ Redis Streams / NATS without touching the SSE machinery.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install "mgf-livepush[redis,fastapi]" # adapters are opt-in extras
55
+ ```
56
+
57
+ ## Quickstart (FastAPI)
58
+
59
+ ```python
60
+ from redis.asyncio import Redis
61
+ from mgf.livepush import SSEStreamService, RedisBroker, SlotCap
62
+ from mgf.livepush.fastapi import sse_streaming_response
63
+
64
+ redis = Redis.from_url("redis://localhost:6379/0")
65
+ sse = SSEStreamService(RedisBroker(redis), SlotCap(redis, cap=20))
66
+
67
+ @router.get("/events/stream")
68
+ async def stream(request: Request):
69
+ # 429 (Retry-After: 5) automatically when the per-key cap is full.
70
+ return await sse_streaming_response(
71
+ sse, request, channel=f"events:{tenant_id}", slot_key=str(tenant_id)
72
+ )
73
+ ```
74
+
75
+ Publisher side, anywhere:
76
+
77
+ ```python
78
+ await RedisBroker(redis).publish(f"events:{tenant_id}", json.dumps(event))
79
+ ```
80
+
81
+ Framework-agnostic core: `open_stream(...)` returns an `SSEStream`
82
+ (`.headers` + `.body` async byte iterator) — wrap `.body` in whatever
83
+ streaming response your framework uses. Tests use `MockBroker` (no
84
+ Redis).
85
+
86
+ ## The dev/prod-proxy SSE recipe (read this — it's the tarpit)
87
+
88
+ A FastAPI `text/event-stream` endpoint behind a proxy (SvelteKit `vite`,
89
+ a `+server.ts` `[...path]` forward, nginx, AWS ALB) will silently
90
+ **buffer** the body and break your latency budget even though the API
91
+ side is correct. To get live push through a proxy:
92
+
93
+ 1. **Response headers (set by this lib):** `Cache-Control: no-cache,
94
+ no-transform` + `X-Accel-Buffering: no`. If your proxy gzips, also
95
+ force `Content-Encoding: identity` on the proxied response.
96
+ 2. **Node `fetch` forwarding** of a streamed body needs `duplex: 'half'`.
97
+ 3. **Playwright:** `waitForLoadState('networkidle')` *never fires* with
98
+ an open `EventSource` (the long-lived connection counts as in-flight)
99
+ — wait on a concrete readiness signal instead.
100
+ 4. **Playwright:** `webServer` spawns *before* `globalSetup`, so any env
101
+ the proxy needs must be declared statically in `webServer.env`, not
102
+ threaded from `globalSetup`.
103
+
104
+ ## Public surface
105
+
106
+ | Name | What |
107
+ |---|---|
108
+ | `SSEStreamService` | acquire slot → subscribe → stream (`open_stream`) |
109
+ | `SSEStream` / `sse_headers` | the `(headers, body)` result + the proxy-safe headers |
110
+ | `Broker` / `Subscription` | the pub/sub port (Protocols) |
111
+ | `RedisBroker` / `MockBroker` | adapters (`[redis]` extra / in-process) |
112
+ | `SlotCap` | per-key INCR/DECR connection cap, fail-open, TTL |
113
+ | `CapExceededError` / `LivePushError` | typed errors (subclass `AppError`) |
114
+ | `mgf.livepush.fastapi.sse_streaming_response` | one-line FastAPI wrapper (`[fastapi]` extra) |
@@ -0,0 +1,11 @@
1
+ mgf/livepush/__init__.py,sha256=qmWG8GjWYVH6R7nu-kN68YJs1Mbfv3in4FlnLh69KPU,1265
2
+ mgf/livepush/_broker.py,sha256=5idCJaM_hd__rqrW8WwOflWdkR9QTqpB8-zJHitV6jY,4181
3
+ mgf/livepush/_errors.py,sha256=jehN-qXpCIK0OtgLT65ab9s1Jqjc-9hUQJKRUreNDBQ,866
4
+ mgf/livepush/_slotcap.py,sha256=G2S5LEO2xiq0R1Wo_OIQsMLFl2xmHQZT63T4Ku-XCPg,3135
5
+ mgf/livepush/_sse.py,sha256=7ePvjxYLT56PDCdGqvCXO3l6W0VRMFScsOn9ygHo52M,4160
6
+ mgf/livepush/fastapi.py,sha256=mVNa_zE1l1b5LSwvnWRWXSdwVVajtR4Z8WqMFBKcHmc,1426
7
+ mgf/livepush/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ mgf_livepush-0.1.2.dist-info/METADATA,sha256=1RLh8KzO5IUDS-OiaZ9v71KGHKjJYypBeBWX2XwSZQU,4742
9
+ mgf_livepush-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ mgf_livepush-0.1.2.dist-info/licenses/LICENSE,sha256=608k_GqtNLHawes6fp-TlRhHA2Ha6rGGenwLZPN-j9U,1101
11
+ mgf_livepush-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bassam Alsanie and mgf-livepush contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.