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.
- caprid_moq_core-0.1.0.dist-info/METADATA +43 -0
- caprid_moq_core-0.1.0.dist-info/RECORD +19 -0
- caprid_moq_core-0.1.0.dist-info/WHEEL +4 -0
- caprid_moq_core-0.1.0.dist-info/licenses/LICENSE +20 -0
- caprid_moq_core-0.1.0.dist-info/licenses/NOTICE +6 -0
- moq_core/__init__.py +150 -0
- moq_core/_tasks.py +68 -0
- moq_core/bytes.py +199 -0
- moq_core/control_messages.py +1759 -0
- moq_core/errors.py +65 -0
- moq_core/object_stream.py +414 -0
- moq_core/publish_session.py +1391 -0
- moq_core/py.typed +0 -0
- moq_core/request_stream.py +156 -0
- moq_core/session.py +1391 -0
- moq_core/subgroup_writer.py +242 -0
- moq_core/track_source.py +44 -0
- moq_core/transport.py +167 -0
- moq_core/varint.py +91 -0
|
@@ -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,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"]
|