caprid-moq-core 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.
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: caprid-moq-core
3
+ Version: 0.1.0
4
+ Summary: Transport-independent MOQT track primitives
5
+ License-Expression: Apache-2.0
6
+ License-File: LICENSE
7
+ License-File: NOTICE
8
+ Requires-Python: >=3.11
9
+ Project-URL: Repository, https://github.com/caprids/relay-sdks
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Generic MoQ core (Python)
13
+
14
+ An independently buildable package for MOQT sessions and arbitrary track objects. It has no runtime
15
+ dependencies and does not import Relay product code or any transport library.
16
+
17
+ `create_moqt_session` owns a supplied, established transport. Subscribe, fetch, and track-status
18
+ requests use callback handlers, preserve uninterpreted payloads and object properties, and expose
19
+ protocol lifecycle notifications. `MoqTrackSource` describes borrowed track access without shutdown.
20
+
21
+ `create_moqt_publish_session` serves a namespace and its registered tracks over a separate
22
+ transport. Track properties are explicit, with no default media timescale. `create_subgroup_writer`
23
+ writes arbitrary object payloads and metadata; the SDK supplies the bounded, single-object-group
24
+ publishing policy above it.
25
+
26
+ The transport seam is `moq_core.transport.MoqtTransport`: the structural subset of a WebTransport
27
+ session the drivers need, spelled for asyncio. `caprid_relay` supplies the aioquic implementation and
28
+ tests supply in-memory pairs.
29
+
30
+ This package follows MOQT draft 22, and draft 20 for a peer that selects it, and receives subscription objects over reliable streams. It does
31
+ not implement application timing, decoding, datagram reception, or automatic session recovery.
32
+
33
+ The implementation is a port of `ts/packages/moq-core`, which is itself an attributed snapshot of the
34
+ Video.js protocol modules under Apache-2.0. See `NOTICE` and `UPSTREAM.json`.
35
+
36
+ For Relay application usage and local installation, start with the [Python quickstart](../../README.md#first-round-trip).
37
+
38
+ The distribution on PyPI is `caprid-moq-core`; the import name is `moq_core`.
39
+
40
+ ## Stability
41
+
42
+ Before 1.0, a minor version can break the API. This package releases together with `caprid-relay` at
43
+ the same version; see the [changelog](../../CHANGELOG.md).
@@ -0,0 +1,19 @@
1
+ moq_core/__init__.py,sha256=zGc6-SuvVGrJzpHSRfXp4alpY6FUIASxs1WKQZ5Zqrs,3779
2
+ moq_core/_tasks.py,sha256=sVmoZXzl8wpy3fDJE6Yyv5vPYsFPCUKdDMXii5xbS3I,2151
3
+ moq_core/bytes.py,sha256=nusEp_PTrzsdYt5Z1dRuZ_oisC15G1fkucDyXY74bKQ,6450
4
+ moq_core/control_messages.py,sha256=x_xNp7qZpVSIB2xqvM_ysPW_ZRkJpKu7mkmq964eksk,66512
5
+ moq_core/errors.py,sha256=84f7MP_ym6IRVOnTz2XbliVz3al8qK0IJKbR9XvJSKQ,2319
6
+ moq_core/object_stream.py,sha256=4blGp0GPMOOnkHjC4RjlpmIfbvEvFaDwevTgpcSdWmc,15839
7
+ moq_core/publish_session.py,sha256=rhDJedPsG_Q-lKcyaE-Ef2d1AJPaozj6BpGT6Xvn0Xw,63794
8
+ moq_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ moq_core/request_stream.py,sha256=j5rl_G8r-D6Z5hWPeqexDc26OvZGbvzCeCu3gz3G3q4,5827
10
+ moq_core/session.py,sha256=7ww54AA8eaumBmpz7EN0sZr6SoJAriVr6_G8WwS02sI,58224
11
+ moq_core/subgroup_writer.py,sha256=9XQVpbhLndcW1O1xV1_KBM4_oT1-UAqiC8gqsoTK3mI,9183
12
+ moq_core/track_source.py,sha256=pVH48ENq1NLiekCvNTGGszS9ghHP8dKqfwbD4uVGjPI,1416
13
+ moq_core/transport.py,sha256=yi90PB_UT1oovGvAxx5nEU9vB-au4UbxV1wE_cMkq8U,6363
14
+ moq_core/varint.py,sha256=O6wjBn4S0XmNT5EoGL8rchoG3O1Ti4o9ABgvkApY2gY,3193
15
+ caprid_moq_core-0.1.0.dist-info/licenses/LICENSE,sha256=21Hob9QKsIT6YVb2B3SZW2sqHby_IAc5MTG2YAHM5sY,870
16
+ caprid_moq_core-0.1.0.dist-info/licenses/NOTICE,sha256=dhRVybYfLgWRX64eNklLX6TnL1Vxo9SOi5xUo6T82Ak,376
17
+ caprid_moq_core-0.1.0.dist-info/WHEEL,sha256=WKk67IZPzp9uSkBO-iWoZzrqp9-AwH7FE9EB8EoJbf4,81
18
+ caprid_moq_core-0.1.0.dist-info/METADATA,sha256=qgQCDVLNPdS0cMIElGU9pOmN0ka7nhMs_bSw2AGZf_k,2162
19
+ caprid_moq_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.12
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,20 @@
1
+ Copyright 2025-present Video.js contributors
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
14
+
15
+ ------------------------------------------------------------------
16
+
17
+ Trademark Notice:
18
+ “Video.js” is a registered trademark of Brightcove Inc.
19
+ The project is maintained independently by the open-source community,
20
+ governed by the Video.js Technical Steering Committee.
@@ -0,0 +1,6 @@
1
+ The protocol implementation and its initial tests are a Python port of the TypeScript
2
+ @caprid/moq-core package (ts/packages/moq-core in this repository), which is derived from Video.js.
3
+ Copyright 2025-present Video.js contributors.
4
+ Licensed under the Apache License, Version 2.0. See LICENSE.
5
+
6
+ See UPSTREAM.json for the source revision, original file hashes, and adaptations.
moq_core/__init__.py ADDED
@@ -0,0 +1,150 @@
1
+ """Transport-independent MOQT track primitives.
2
+
3
+ The Python counterpart of ``ts/packages/moq-core``: protocol encoding, sessions, and stream
4
+ handling over an abstract transport, with no runtime dependencies and no knowledge of Relay.
5
+ ``MOQT_PROTOCOL_ID`` is the default draft every SDK shares and ``MOQT_PROTOCOL_IDS`` the drafts a
6
+ client offers; ``spec/protocol.json`` is their source of truth and ``tests/test_spec_protocol.py``
7
+ checks they agree.
8
+ """
9
+
10
+ from .control_messages import (
11
+ MAX_NAMESPACE_FIELDS,
12
+ MOQT_PROTOCOL_ID,
13
+ MOQT_PROTOCOL_IDS,
14
+ PUBLISH_DONE_STATUS,
15
+ REQUEST_ERROR_CODE,
16
+ AbsoluteLocationFilter,
17
+ FetchOk,
18
+ Goaway,
19
+ GroupOrder,
20
+ KeyValuePair,
21
+ Location,
22
+ LocationFilter,
23
+ MessageParameters,
24
+ MoqtProtocolId,
25
+ NextObjectFilter,
26
+ NoLocationFilter,
27
+ PublishDone,
28
+ RelativeGroupFilter,
29
+ RequestError,
30
+ SubscribeOk,
31
+ TrackNamespace,
32
+ )
33
+ from .errors import SESSION_ERROR, MoqtProtocolError, is_moqt_protocol_error
34
+ from .object_stream import (
35
+ MAX_OBJECT_PAYLOAD_LENGTH,
36
+ FetchEndOfRange,
37
+ FetchObject,
38
+ FetchStreamEntry,
39
+ MoqtObject,
40
+ ObjectStatus,
41
+ )
42
+ from .publish_session import (
43
+ MAX_TRACK_PROPERTIES_LENGTH,
44
+ AnnounceEnded,
45
+ IncomingRequestUpdate,
46
+ MoqtPublishSession,
47
+ MoqtPublishSessionCallbacks,
48
+ MoqtPublishSessionConfig,
49
+ RegisteredTrack,
50
+ RegisterTrackOptions,
51
+ SubscribeFailed,
52
+ TrackBinding,
53
+ create_moqt_publish_session,
54
+ )
55
+ from .session import (
56
+ Closed,
57
+ FetchHandle,
58
+ FetchHandlers,
59
+ FetchReset,
60
+ MoqtFetchOptions,
61
+ MoqtSession,
62
+ MoqtSessionCallbacks,
63
+ MoqtSessionConfig,
64
+ MoqtSubscribeNamespaceOptions,
65
+ MoqtSubscribeOptions,
66
+ NamespaceSubscription,
67
+ NamespaceSubscriptionHandlers,
68
+ PublishResponder,
69
+ SubgroupEnd,
70
+ SubgroupReset,
71
+ Subscription,
72
+ SubscriptionHandlers,
73
+ TrackStatusHandlers,
74
+ TrackStatusOk,
75
+ create_moqt_session,
76
+ )
77
+ from .subgroup_writer import SubgroupWriter, create_subgroup_writer
78
+ from .track_source import MoqTrackSource
79
+ from .transport import BidirectionalStream, MoqtTransport, ReceiveStream, SendStream
80
+
81
+ __all__ = [
82
+ "MAX_NAMESPACE_FIELDS",
83
+ "MAX_OBJECT_PAYLOAD_LENGTH",
84
+ "MAX_TRACK_PROPERTIES_LENGTH",
85
+ "MOQT_PROTOCOL_ID",
86
+ "MOQT_PROTOCOL_IDS",
87
+ "PUBLISH_DONE_STATUS",
88
+ "REQUEST_ERROR_CODE",
89
+ "SESSION_ERROR",
90
+ "AbsoluteLocationFilter",
91
+ "AnnounceEnded",
92
+ "BidirectionalStream",
93
+ "Closed",
94
+ "FetchEndOfRange",
95
+ "FetchHandle",
96
+ "FetchHandlers",
97
+ "FetchObject",
98
+ "FetchOk",
99
+ "FetchReset",
100
+ "FetchStreamEntry",
101
+ "Goaway",
102
+ "GroupOrder",
103
+ "IncomingRequestUpdate",
104
+ "KeyValuePair",
105
+ "Location",
106
+ "LocationFilter",
107
+ "MessageParameters",
108
+ "MoqTrackSource",
109
+ "MoqtFetchOptions",
110
+ "MoqtObject",
111
+ "MoqtProtocolError",
112
+ "MoqtProtocolId",
113
+ "MoqtPublishSession",
114
+ "MoqtPublishSessionCallbacks",
115
+ "MoqtPublishSessionConfig",
116
+ "MoqtSession",
117
+ "MoqtSessionCallbacks",
118
+ "MoqtSessionConfig",
119
+ "MoqtSubscribeNamespaceOptions",
120
+ "MoqtSubscribeOptions",
121
+ "MoqtTransport",
122
+ "NamespaceSubscription",
123
+ "NamespaceSubscriptionHandlers",
124
+ "NextObjectFilter",
125
+ "NoLocationFilter",
126
+ "ObjectStatus",
127
+ "PublishDone",
128
+ "PublishResponder",
129
+ "ReceiveStream",
130
+ "RegisterTrackOptions",
131
+ "RegisteredTrack",
132
+ "RelativeGroupFilter",
133
+ "RequestError",
134
+ "SendStream",
135
+ "SubgroupEnd",
136
+ "SubgroupReset",
137
+ "SubgroupWriter",
138
+ "SubscribeFailed",
139
+ "SubscribeOk",
140
+ "Subscription",
141
+ "SubscriptionHandlers",
142
+ "TrackBinding",
143
+ "TrackNamespace",
144
+ "TrackStatusHandlers",
145
+ "TrackStatusOk",
146
+ "create_moqt_publish_session",
147
+ "create_moqt_session",
148
+ "create_subgroup_writer",
149
+ "is_moqt_protocol_error",
150
+ ]
moq_core/_tasks.py ADDED
@@ -0,0 +1,68 @@
1
+ """Background-task plumbing shared by the session drivers.
2
+
3
+ The TypeScript core fires promises with ``void`` and attaches ``.catch`` handlers where a failure
4
+ has a meaning. asyncio needs a strong reference to every task it should keep running, and an
5
+ unretrieved task exception is reported as a warning at garbage-collection time, so :func:`spawn`
6
+ keeps that reference and routes the outcome through an optional error handler.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from collections.abc import Callable, Coroutine
14
+ from typing import Any
15
+
16
+ _log = logging.getLogger("moq_core")
17
+ _background: set[asyncio.Task[Any]] = set()
18
+
19
+
20
+ def spawn(
21
+ coroutine: Coroutine[Any, Any, Any],
22
+ *,
23
+ on_error: Callable[[BaseException], None] | None = None,
24
+ ) -> asyncio.Task[Any]:
25
+ """Run ``coroutine`` in the background, keeping it referenced until it finishes.
26
+
27
+ ``on_error`` receives an exception the coroutine raised (never a cancellation). Without it the
28
+ exception is logged at debug level and otherwise dropped, the counterpart of ``.catch(() => {})``.
29
+ """
30
+ task = asyncio.get_running_loop().create_task(coroutine)
31
+ _background.add(task)
32
+
33
+ def finished(done: asyncio.Task[Any]) -> None:
34
+ _background.discard(done)
35
+
36
+ if done.cancelled():
37
+ return
38
+
39
+ error = done.exception()
40
+
41
+ if error is None:
42
+ return
43
+
44
+ if on_error is not None:
45
+ on_error(error)
46
+ else:
47
+ _log.debug("background task failed", exc_info=error)
48
+
49
+ task.add_done_callback(finished)
50
+ return task
51
+
52
+
53
+ def observe(future: asyncio.Future[Any]) -> None:
54
+ """Mark a future's eventual exception as handled without acting on it."""
55
+
56
+ def finished(done: asyncio.Future[Any]) -> None:
57
+ if not done.cancelled():
58
+ done.exception()
59
+
60
+ future.add_done_callback(finished)
61
+
62
+
63
+ def call_later(delay: float, callback: Callable[[], None]) -> asyncio.TimerHandle:
64
+ """Schedule ``callback`` after ``delay`` seconds on the running loop."""
65
+ return asyncio.get_running_loop().call_later(delay, callback)
66
+
67
+
68
+ __all__ = ["call_later", "observe", "spawn"]
moq_core/bytes.py ADDED
@@ -0,0 +1,199 @@
1
+ """Byte-level readers and writers shared by the MOQT wire codecs.
2
+
3
+ :class:`ByteReader` and :class:`ByteWriter` are synchronous cursors over in-memory buffers, used for
4
+ control-message bodies (always fully buffered: the 16-bit message length bounds them).
5
+ :class:`StreamReader` is the asynchronous counterpart for unidirectional data streams, where headers
6
+ and objects arrive incrementally over a transport :class:`~moq_core.transport.ReceiveStream`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import deque
12
+
13
+ from .errors import IncompleteInputError
14
+ from .transport import ReceiveStream
15
+ from .varint import MAX_VARINT_VALUE, decode_varint, encode_varint, varint_length_from_first_byte
16
+
17
+
18
+ def utf8_encode(value: str) -> bytes:
19
+ return value.encode("utf-8")
20
+
21
+
22
+ def utf8_decode(data: bytes | bytearray | memoryview) -> str:
23
+ # Lenient like TextDecoder's default: invalid sequences become U+FFFD instead of failing.
24
+ return bytes(data).decode("utf-8", errors="replace")
25
+
26
+
27
+ class ByteReader:
28
+ """Synchronous cursor over a byte buffer. Every read past the end raises :class:`IncompleteInputError`."""
29
+
30
+ __slots__ = ("_data", "_offset")
31
+
32
+ def __init__(self, data: bytes | bytearray | memoryview, offset: int = 0) -> None:
33
+ self._data = bytes(data)
34
+ self._offset = offset
35
+
36
+ @property
37
+ def offset(self) -> int:
38
+ return self._offset
39
+
40
+ @property
41
+ def remaining(self) -> int:
42
+ return len(self._data) - self._offset
43
+
44
+ def read_varint(self) -> int:
45
+ value, byte_length = decode_varint(self._data, self._offset)
46
+ self._offset += byte_length
47
+ return value
48
+
49
+ def read_count_varint(self) -> int | None:
50
+ """Read a count whose "unknown" sentinel is the maximal varint 2^64-1 (PUBLISH_DONE's Stream Count, §9.9).
51
+
52
+ Exactly that sentinel reads as ``None``; every other value is returned as is.
53
+ """
54
+ value = self.read_varint()
55
+ return None if value == MAX_VARINT_VALUE else value
56
+
57
+ def read_uint8(self) -> int:
58
+ if self.remaining < 1:
59
+ raise IncompleteInputError("read past end of buffer")
60
+
61
+ value = self._data[self._offset]
62
+ self._offset += 1
63
+ return value
64
+
65
+ def read_uint16(self) -> int:
66
+ if self.remaining < 2:
67
+ raise IncompleteInputError("read past end of buffer")
68
+
69
+ value = (self._data[self._offset] << 8) | self._data[self._offset + 1]
70
+ self._offset += 2
71
+ return value
72
+
73
+ def read_bytes(self, length: int) -> bytes:
74
+ if length < 0 or self.remaining < length:
75
+ raise IncompleteInputError("read past end of buffer")
76
+
77
+ view = self._data[self._offset : self._offset + length]
78
+ self._offset += length
79
+ return view
80
+
81
+
82
+ class ByteWriter:
83
+ """Growable byte sink for encoding wire structures."""
84
+
85
+ __slots__ = ("_buffer",)
86
+
87
+ def __init__(self, initial_capacity: int = 0) -> None:
88
+ del initial_capacity # bytearray grows on its own; the parameter keeps call sites symmetric with the TS core
89
+ self._buffer = bytearray()
90
+
91
+ @property
92
+ def length(self) -> int:
93
+ return len(self._buffer)
94
+
95
+ def write_varint(self, value: int) -> None:
96
+ self._buffer += encode_varint(value)
97
+
98
+ def write_uint8(self, value: int) -> None:
99
+ self._buffer.append(value & 0xFF)
100
+
101
+ def write_uint16(self, value: int) -> None:
102
+ self._buffer += bytes(((value >> 8) & 0xFF, value & 0xFF))
103
+
104
+ def write_bytes(self, data: bytes | bytearray | memoryview) -> None:
105
+ self._buffer += data
106
+
107
+ def write_length_prefixed(self, data: bytes | bytearray | memoryview) -> None:
108
+ """Varint length prefix followed by the bytes."""
109
+ self.write_varint(len(data))
110
+ self.write_bytes(data)
111
+
112
+ def to_bytes(self) -> bytes:
113
+ """A copy of the written bytes."""
114
+ return bytes(self._buffer)
115
+
116
+
117
+ class StreamReader:
118
+ """Buffered asynchronous reader over a transport :class:`ReceiveStream`.
119
+
120
+ Reads never split a varint or byte run: each method pulls chunks until it can satisfy the
121
+ request. A clean end-of-stream mid-read raises :class:`IncompleteInputError`; use
122
+ :meth:`at_end` to probe for a clean boundary first. A reset stream raises whatever the transport
123
+ raises.
124
+ """
125
+
126
+ __slots__ = ("_buffered", "_chunks", "_done", "_stream")
127
+
128
+ def __init__(self, stream: ReceiveStream) -> None:
129
+ self._stream = stream
130
+ self._chunks: deque[bytes] = deque()
131
+ self._buffered = 0
132
+ self._done = False
133
+
134
+ async def _pull(self) -> bool:
135
+ if self._done:
136
+ return False
137
+
138
+ chunk = await self._stream.read()
139
+
140
+ if chunk is None:
141
+ self._done = True
142
+ return False
143
+
144
+ if chunk:
145
+ self._chunks.append(bytes(chunk))
146
+ self._buffered += len(chunk)
147
+
148
+ return True
149
+
150
+ async def _fill(self, length: int) -> None:
151
+ while self._buffered < length:
152
+ if not await self._pull():
153
+ raise IncompleteInputError("unexpected end of stream")
154
+
155
+ def _take(self, length: int) -> bytes:
156
+ out = bytearray()
157
+
158
+ while len(out) < length:
159
+ head = self._chunks[0]
160
+ take = min(len(head), length - len(out))
161
+ out += head[:take]
162
+
163
+ if take == len(head):
164
+ self._chunks.popleft()
165
+ else:
166
+ self._chunks[0] = head[take:]
167
+
168
+ self._buffered -= length
169
+ return bytes(out)
170
+
171
+ async def at_end(self) -> bool:
172
+ """True when the stream ended cleanly with no bytes left unread."""
173
+ while self._buffered == 0:
174
+ if not await self._pull():
175
+ return True
176
+
177
+ return False
178
+
179
+ async def read_bytes(self, length: int) -> bytes:
180
+ await self._fill(length)
181
+ return self._take(length)
182
+
183
+ async def read_uint8(self) -> int:
184
+ await self._fill(1)
185
+ return self._take(1)[0]
186
+
187
+ async def read_varint(self) -> int:
188
+ await self._fill(1)
189
+ byte_length = varint_length_from_first_byte(self._chunks[0][0])
190
+ await self._fill(byte_length)
191
+ return decode_varint(self._take(byte_length))[0]
192
+
193
+ def cancel(self, reason: object = None) -> None:
194
+ """Cancel the underlying stream (drop an unwanted data stream)."""
195
+ self._done = True
196
+ self._stream.cancel(reason)
197
+
198
+
199
+ __all__ = ["ByteReader", "ByteWriter", "StreamReader", "utf8_decode", "utf8_encode"]