pyccobs 0.1.0__tar.gz

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,13 @@
1
+ cmake_minimum_required(VERSION 3.15)
2
+ project(pyccobs LANGUAGES C)
3
+
4
+ set(BUILD_SHARED_LIBS ON CACHE BOOL "Build ccobs as a shared library" FORCE)
5
+ set(BUILD_TESTING OFF CACHE BOOL "Do not build ccobs's C tests for the wheel" FORCE)
6
+
7
+ add_subdirectory(vendor/ccobs)
8
+
9
+ install(
10
+ TARGETS ccobs
11
+ LIBRARY DESTINATION ccobs
12
+ RUNTIME DESTINATION ccobs
13
+ )
pyccobs-0.1.0/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Zero-Clause BSD
2
+
3
+ Copyright (C) 2026 Elizabeth Kiara Regina Ashford
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
10
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
11
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
12
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
13
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
14
+ PERFORMANCE OF THIS SOFTWARE.
15
+
@@ -0,0 +1,6 @@
1
+ include CMakeLists.txt
2
+ include vendor/ccobs/LICENSE
3
+ include vendor/ccobs/README.md
4
+ include vendor/ccobs/CMakeLists.txt
5
+ recursive-include vendor/ccobs/include *.h
6
+ recursive-include vendor/ccobs/src *.c
pyccobs-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyccobs
3
+ Version: 0.1.0
4
+ Summary: Portable ctypes bindings for ccobs
5
+ Author: Elizabeth Kiara Regina Ashford
6
+ License-Expression: 0BSD
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: C
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ License-File: vendor/ccobs/LICENSE
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7; extra == "test"
15
+ Dynamic: license-file
16
+
17
+ # pyccobs
18
+
19
+ `pyccobs` provides Python bindings to [ccobs](https://github.com/Elizafox/ccobs)
20
+ using only the standard library's `ctypes` module. The ccobs C library is
21
+ vendored and built into the package, so a separate ccobs installation is not
22
+ required. It includes one-shot and streaming APIs as well as exposing the
23
+ native library handle.
24
+
25
+ ## Build and use
26
+
27
+ Python 3.11+ and CMake 3.15+ are required.
28
+
29
+ ```sh
30
+ python -m pip install -e .
31
+ ```
32
+
33
+ The package build invokes the vendored ccobs CMake project directly. Because
34
+ the package uses `ctypes` rather than the CPython extension API, the resulting
35
+ wheel is tagged for Python 3 generally (for example, `py3-none-win_amd64`).
36
+
37
+ Set `CCOBS_LIBRARY_PATH` to a compatible shared library (or its directory) to
38
+ override the bundled copy when testing a different ccobs build.
39
+
40
+ ```python
41
+ import ccobs
42
+
43
+ wire = ccobs.encode(b"hello\0world")
44
+ assert ccobs.decode(wire) == b"hello\0world"
45
+
46
+ batch = ccobs.encodev([b"first", b"", b"third"])
47
+ assert batch == b"".join(ccobs.encode(item) for item in [b"first", b"", b"third"])
48
+
49
+ encoder = ccobs.Encoder()
50
+ wire = encoder.feed(b"hello ") + encoder.feed(b"world", end_message=True)
51
+ wire += encoder.feedv([b"second", b"third"])
52
+ encoder.close()
53
+
54
+ decoder = ccobs.Decoder()
55
+ decoded, used, frame_sizes = decoder.feedv(wire)
56
+ decoder.close()
57
+ ```
58
+
59
+ `Decoder.feed()` returns `(decoded_bytes, consumed_bytes, message_complete)`
60
+ and stops after the first complete frame. `Decoder.feedv()` returns
61
+ `(decoded_bytes, consumed_bytes, frame_sizes)` and
62
+ decodes every complete frame in the supplied chunk in one `ccobs_decodev()`
63
+ call. `frame_sizes` gives the decoded size of each completed frame in the flat
64
+ output; a size may include bytes returned by an earlier partial call.
65
+ `Encoder.feedv()` encodes a batch of complete messages through the native
66
+ streaming vector API. Both stateful classes are context managers and have
67
+ `reset()` and `close()`.
68
+
69
+ For direct access to `ccobs_create`, `ccobs_encodev`, and the rest of the C API,
70
+ use `ccobs.load_library()`. Its functions have their ctypes `argtypes` and
71
+ `restype` declarations installed.
@@ -0,0 +1,55 @@
1
+ # pyccobs
2
+
3
+ `pyccobs` provides Python bindings to [ccobs](https://github.com/Elizafox/ccobs)
4
+ using only the standard library's `ctypes` module. The ccobs C library is
5
+ vendored and built into the package, so a separate ccobs installation is not
6
+ required. It includes one-shot and streaming APIs as well as exposing the
7
+ native library handle.
8
+
9
+ ## Build and use
10
+
11
+ Python 3.11+ and CMake 3.15+ are required.
12
+
13
+ ```sh
14
+ python -m pip install -e .
15
+ ```
16
+
17
+ The package build invokes the vendored ccobs CMake project directly. Because
18
+ the package uses `ctypes` rather than the CPython extension API, the resulting
19
+ wheel is tagged for Python 3 generally (for example, `py3-none-win_amd64`).
20
+
21
+ Set `CCOBS_LIBRARY_PATH` to a compatible shared library (or its directory) to
22
+ override the bundled copy when testing a different ccobs build.
23
+
24
+ ```python
25
+ import ccobs
26
+
27
+ wire = ccobs.encode(b"hello\0world")
28
+ assert ccobs.decode(wire) == b"hello\0world"
29
+
30
+ batch = ccobs.encodev([b"first", b"", b"third"])
31
+ assert batch == b"".join(ccobs.encode(item) for item in [b"first", b"", b"third"])
32
+
33
+ encoder = ccobs.Encoder()
34
+ wire = encoder.feed(b"hello ") + encoder.feed(b"world", end_message=True)
35
+ wire += encoder.feedv([b"second", b"third"])
36
+ encoder.close()
37
+
38
+ decoder = ccobs.Decoder()
39
+ decoded, used, frame_sizes = decoder.feedv(wire)
40
+ decoder.close()
41
+ ```
42
+
43
+ `Decoder.feed()` returns `(decoded_bytes, consumed_bytes, message_complete)`
44
+ and stops after the first complete frame. `Decoder.feedv()` returns
45
+ `(decoded_bytes, consumed_bytes, frame_sizes)` and
46
+ decodes every complete frame in the supplied chunk in one `ccobs_decodev()`
47
+ call. `frame_sizes` gives the decoded size of each completed frame in the flat
48
+ output; a size may include bytes returned by an earlier partial call.
49
+ `Encoder.feedv()` encodes a batch of complete messages through the native
50
+ streaming vector API. Both stateful classes are context managers and have
51
+ `reset()` and `close()`.
52
+
53
+ For direct access to `ccobs_create`, `ccobs_encodev`, and the rest of the C API,
54
+ use `ccobs.load_library()`. Its functions have their ctypes `argtypes` and
55
+ `restype` declarations installed.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyccobs"
7
+ version = "0.1.0"
8
+ description = "Portable ctypes bindings for ccobs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "0BSD"
12
+ license-files = ["LICENSE", "vendor/ccobs/LICENSE"]
13
+ authors = [{ name = "Elizabeth Kiara Regina Ashford" }]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: C",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ test = ["pytest>=7"]
21
+
22
+ [tool.setuptools]
23
+ package-dir = { "" = "src" }
24
+ include-package-data = true
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+
32
+ [tool.ruff]
33
+ target-version = "py311"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E4", "E7", "E9", "F", "I"]
37
+
38
+ [tool.cibuildwheel]
39
+ # ctypes wheels are independent of the Python ABI; build once per platform.
40
+ build = ["cp311-manylinux_*", "cp311-macosx_*", "cp311-win_amd64"]
41
+ archs = ["native"]
42
+ before-build = 'python -m pip install "cmake>=3.15"'
43
+ test-requires = ["pytest>=7"]
44
+ test-sources = ["tests"]
45
+ test-command = "python -m pytest tests"
46
+
47
+ [tool.cibuildwheel.linux]
48
+ manylinux-x86_64-image = "manylinux2014"
49
+ manylinux-aarch64-image = "manylinux2014"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
pyccobs-0.1.0/setup.py ADDED
@@ -0,0 +1,85 @@
1
+ """Build the bundled ctypes library with its native CMake project."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from setuptools import Extension, setup
8
+ from setuptools.command.bdist_wheel import bdist_wheel
9
+ from setuptools.command.build_ext import build_ext
10
+
11
+
12
+ class CMakeExtension(Extension):
13
+ """A native library built by CMake rather than setuptools's compiler."""
14
+
15
+ def __init__(self, name: str) -> None:
16
+ super().__init__(name, sources=[])
17
+
18
+
19
+ class CMakeBuild(build_ext):
20
+ """Configure, build, and install ccobs into the staged Python package."""
21
+
22
+ def build_extension(self, ext: Extension) -> None:
23
+ source_dir = Path(__file__).resolve().parent
24
+ build_dir = Path(self.build_temp).resolve() / ext.name
25
+ extension_path = Path(self.get_ext_fullpath(ext.name)).resolve()
26
+ install_root = extension_path.parents[1]
27
+ config = "Debug" if self.debug else "Release"
28
+
29
+ # Don't let stale artifacts from previous builds leak into the wheel.
30
+ extension_path.unlink(missing_ok=True)
31
+ build_dir.mkdir(parents=True, exist_ok=True)
32
+ configure_command = [
33
+ "cmake",
34
+ "-S",
35
+ str(source_dir),
36
+ "-B",
37
+ str(build_dir),
38
+ f"-DCMAKE_BUILD_TYPE={config}",
39
+ ]
40
+ generator = os.environ.get("CMAKE_GENERATOR")
41
+ if generator:
42
+ configure_command.extend(["-G", generator])
43
+ if (
44
+ os.name == "nt"
45
+ and (not generator or generator.startswith("Visual Studio"))
46
+ and "CMAKE_GENERATOR_PLATFORM" not in os.environ
47
+ ):
48
+ architecture = {
49
+ "win32": "Win32",
50
+ "win-amd64": "x64",
51
+ "win-arm64": "ARM64",
52
+ }.get(self.plat_name)
53
+ if architecture:
54
+ configure_command.extend(["-A", architecture])
55
+
56
+ subprocess.run(configure_command, check=True)
57
+ subprocess.run(
58
+ ["cmake", "--build", str(build_dir), "--config", config], check=True
59
+ )
60
+ subprocess.run(
61
+ [
62
+ "cmake",
63
+ "--install",
64
+ str(build_dir),
65
+ "--config",
66
+ config,
67
+ "--prefix",
68
+ str(install_root),
69
+ ],
70
+ check=True,
71
+ )
72
+
73
+
74
+ class PlatformWheel(bdist_wheel):
75
+ """Tag the ctypes wheel as Python-independent but platform-specific."""
76
+
77
+ def get_tag(self) -> tuple[str, str, str]:
78
+ _, _, platform = super().get_tag()
79
+ return "py3", "none", platform
80
+
81
+
82
+ setup(
83
+ ext_modules=[CMakeExtension("ccobs._ccobs")],
84
+ cmdclass={"build_ext": CMakeBuild, "bdist_wheel": PlatformWheel},
85
+ )
@@ -0,0 +1,312 @@
1
+ """Pythonic ctypes bindings for the ccobs C library."""
2
+
3
+ import ctypes
4
+ import os
5
+ from collections.abc import Iterable
6
+ from enum import IntEnum
7
+ from typing import Optional
8
+
9
+ from ._lib import BytePointer, StatePointer, bind
10
+
11
+ __all__ = [
12
+ "Decoder",
13
+ "Encoder",
14
+ "Error",
15
+ "MalformedInputError",
16
+ "CcobsError",
17
+ "decode",
18
+ "encode",
19
+ "encodev",
20
+ "load_library",
21
+ ]
22
+
23
+
24
+ class Error(IntEnum):
25
+ OK = 0
26
+ PARTIAL = 1
27
+ NEED_OUTPUT = 2
28
+ NULL_ARGUMENT = 3
29
+ MALFORMED_INPUT = 4
30
+ OOM = 5
31
+
32
+
33
+ class CcobsError(Exception):
34
+ """An error reported by the native ccobs library."""
35
+
36
+ def __init__(self, code: int, message: str):
37
+ self.code = Error(code) if code in Error._value2member_map_ else code
38
+ super().__init__(message)
39
+
40
+
41
+ class MalformedInputError(CcobsError):
42
+ """The encoded stream contains a malformed COBS frame."""
43
+
44
+
45
+ _library: Optional[ctypes.CDLL] = None
46
+
47
+
48
+ def load_library(path: Optional[os.PathLike[str] | str] = None) -> ctypes.CDLL:
49
+ """Load and return ccobs, optionally from an explicit path.
50
+
51
+ Calling this with a path replaces the process-wide library used by new
52
+ encoders and decoders. Existing objects retain their original library.
53
+ """
54
+ global _library
55
+ if path is not None or _library is None:
56
+ _library = bind(path)
57
+ return _library
58
+
59
+
60
+ def _message(lib: ctypes.CDLL, code: int) -> str:
61
+ value = lib.ccobs_error_string(code)
62
+ return value.decode("utf-8", "replace") if value else f"ccobs error {code}"
63
+
64
+
65
+ def _raise_error(lib: ctypes.CDLL, code: int) -> None:
66
+ """Wrapper to turn ccobs error into idiomatic Python errors."""
67
+ cls = MalformedInputError if code == Error.MALFORMED_INPUT else CcobsError
68
+ raise cls(code, _message(lib, code))
69
+
70
+
71
+ def _input_buffer(data: bytes) -> tuple[object | None, BytePointer]:
72
+ if not data:
73
+ return None, BytePointer()
74
+ owner = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)
75
+ return owner, ctypes.cast(owner, BytePointer)
76
+
77
+
78
+ class _State:
79
+ """Wrapper to internal ccobs state."""
80
+
81
+ def __init__(self, library: Optional[ctypes.CDLL] = None):
82
+ self._lib = library or load_library()
83
+ error = ctypes.c_int(Error.OK)
84
+ self._state: StatePointer = self._lib.ccobs_create(ctypes.byref(error))
85
+ if not self._state:
86
+ _raise_error(self._lib, error.value)
87
+
88
+ def close(self) -> None:
89
+ state = getattr(self, "_state", None)
90
+ if state:
91
+ self._lib.ccobs_destroy(state)
92
+ self._state = StatePointer()
93
+
94
+ def reset(self) -> None:
95
+ code = self._lib.ccobs_reset(self._state)
96
+ if code != Error.OK:
97
+ _raise_error(self._lib, code)
98
+
99
+ def __enter__(self):
100
+ return self
101
+
102
+ def __exit__(self, *args) -> None:
103
+ self.close()
104
+
105
+ def __del__(self):
106
+ self.close()
107
+
108
+
109
+ class Encoder(_State):
110
+ """Incrementally encode message chunks or batches of complete messages."""
111
+
112
+ _OUTPUT_CHUNK_SIZE = 64 * 1024
113
+
114
+ def feed(self, data: object = b"", *, end_message: bool = False) -> bytes:
115
+ raw = memoryview(data).cast("B").tobytes()
116
+ if end_message:
117
+ return self.feedv((raw,))
118
+
119
+ owner, _ = _input_buffer(raw)
120
+ offset = 0
121
+ result = bytearray()
122
+ # 257 also drains a full native block retained from an earlier call.
123
+ capacity = max(257, int(self._lib.ccobs_encode_max_size(len(raw))))
124
+ while True:
125
+ output = (ctypes.c_uint8 * capacity)()
126
+ available = ctypes.c_size_t(len(raw) - offset)
127
+ size = ctypes.c_size_t(capacity)
128
+ current = (
129
+ ctypes.cast(ctypes.byref(owner, offset), BytePointer)
130
+ if owner
131
+ else BytePointer()
132
+ )
133
+ code = self._lib.ccobs_encode(
134
+ self._state,
135
+ current,
136
+ ctypes.byref(available),
137
+ output,
138
+ ctypes.byref(size),
139
+ end_message,
140
+ )
141
+ offset += available.value
142
+ result.extend(output[: size.value])
143
+
144
+ if code == Error.NEED_OUTPUT:
145
+ continue
146
+ if code not in (Error.OK, Error.PARTIAL):
147
+ _raise_error(self._lib, code)
148
+
149
+ return bytes(result)
150
+
151
+ def feedv(self, messages: Iterable[object]) -> bytes:
152
+ """Encode complete, independent messages into one flat framed stream.
153
+
154
+ The encoder state is retained between calls. If :meth:`feed` previously
155
+ supplied an unfinished message, the first message completes it.
156
+ """
157
+ pending = [memoryview(message).cast("B").tobytes() for message in messages]
158
+ if not pending:
159
+ return b""
160
+
161
+ offsets = [0] * len(pending)
162
+ result = bytearray()
163
+ while pending:
164
+ count = len(pending)
165
+ owners: list[object | None] = []
166
+ pointers = (BytePointer * count)()
167
+ sizes = (ctypes.c_size_t * count)()
168
+ maximum = 0
169
+ for index, raw in enumerate(pending):
170
+ owner, pointer = _input_buffer(raw)
171
+ owners.append(owner)
172
+ offset = offsets[index]
173
+ pointers[index] = (
174
+ ctypes.cast(ctypes.byref(owner, offset), BytePointer)
175
+ if owner
176
+ else pointer
177
+ )
178
+ sizes[index] = len(raw) - offset
179
+ maximum += int(self._lib.ccobs_encode_max_size(sizes[index]))
180
+
181
+ capacity = max(257, min(maximum, self._OUTPUT_CHUNK_SIZE))
182
+ output = (ctypes.c_uint8 * capacity)()
183
+ completed = ctypes.c_size_t(count)
184
+ written = ctypes.c_size_t(capacity)
185
+ code = self._lib.ccobs_encodev(
186
+ self._state,
187
+ pointers,
188
+ sizes,
189
+ ctypes.byref(completed),
190
+ output,
191
+ ctypes.byref(written),
192
+ )
193
+ result.extend(output[: written.value])
194
+
195
+ for index in range(min(completed.value + 1, count)):
196
+ offsets[index] += sizes[index]
197
+ if completed.value:
198
+ del pending[: completed.value]
199
+ del offsets[: completed.value]
200
+
201
+ if code == Error.NEED_OUTPUT:
202
+ continue
203
+ if code != Error.OK:
204
+ _raise_error(self._lib, code)
205
+ if completed.value != count:
206
+ raise CcobsError(
207
+ Error.PARTIAL, "ccobs_encodev did not complete the batch"
208
+ )
209
+
210
+ return bytes(result)
211
+
212
+
213
+ class Decoder(_State):
214
+ """Incrementally decode framed stream chunks."""
215
+
216
+ def feed(self, data: object = b"") -> tuple[bytes, int, bool]:
217
+ """Decode at most one frame and report whether it was completed."""
218
+ raw = memoryview(data).cast("B").tobytes()
219
+ owner, _ = _input_buffer(raw)
220
+ offset = 0
221
+ result = bytearray()
222
+ complete = ctypes.c_bool(False)
223
+ capacity = max(1, len(raw))
224
+ while True:
225
+ output = (ctypes.c_uint8 * capacity)()
226
+ available = ctypes.c_size_t(len(raw) - offset)
227
+ size = ctypes.c_size_t(capacity)
228
+ current = (
229
+ ctypes.cast(ctypes.byref(owner, offset), BytePointer)
230
+ if owner
231
+ else BytePointer()
232
+ )
233
+ code = self._lib.ccobs_decode(
234
+ self._state,
235
+ current,
236
+ ctypes.byref(available),
237
+ output,
238
+ ctypes.byref(size),
239
+ ctypes.byref(complete),
240
+ )
241
+ offset += available.value
242
+ result.extend(output[: size.value])
243
+ if code == Error.NEED_OUTPUT:
244
+ continue
245
+ if code not in (Error.OK, Error.PARTIAL):
246
+ _raise_error(self._lib, code)
247
+
248
+ return bytes(result), offset, complete.value
249
+
250
+ def feedv(self, data: object = b"") -> tuple[bytes, int, tuple[int, ...]]:
251
+ """Decode framed stream data into one flat byte string.
252
+
253
+ The decoder state is retained between calls. The returned tuple contains
254
+ the decoded bytes, consumed input byte count, and completed frame sizes.
255
+ If a frame began in a previous call, its size includes bytes returned by
256
+ that call.
257
+ """
258
+ raw = memoryview(data).cast("B").tobytes()
259
+ owner, _ = _input_buffer(raw)
260
+ output_capacity = max(1, len(raw))
261
+ output = (ctypes.c_uint8 * output_capacity)()
262
+ available = ctypes.c_size_t(len(raw))
263
+ written = ctypes.c_size_t(output_capacity)
264
+
265
+ # Every complete encoded frame ends in zero. The extra slot lets
266
+ # decodev consume a partial frame following the final delimiter.
267
+ frame_capacity = raw.count(0) + 1
268
+ frame_sizes = (ctypes.c_size_t * frame_capacity)()
269
+ frame_count = ctypes.c_size_t(frame_capacity)
270
+ code = self._lib.ccobs_decodev(
271
+ self._state,
272
+ ctypes.cast(owner, BytePointer) if owner else BytePointer(),
273
+ ctypes.byref(available),
274
+ output,
275
+ ctypes.byref(written),
276
+ frame_sizes,
277
+ ctypes.byref(frame_count),
278
+ )
279
+ if code not in (Error.OK, Error.PARTIAL):
280
+ _raise_error(self._lib, code)
281
+
282
+ sizes = tuple(frame_sizes[: frame_count.value])
283
+ return bytes(output[: written.value]), available.value, sizes
284
+
285
+
286
+ def encode(data: object, *, library: Optional[ctypes.CDLL] = None) -> bytes:
287
+ """Encode one bytes-like message, including its trailing zero delimiter."""
288
+ with Encoder(library) as encoder:
289
+ return encoder.feed(data, end_message=True)
290
+
291
+
292
+ def encodev(
293
+ messages: Iterable[object], *, library: Optional[ctypes.CDLL] = None
294
+ ) -> bytes:
295
+ """Encode a batch of bytes-like messages into one flat framed stream."""
296
+ with Encoder(library) as encoder:
297
+ return encoder.feedv(messages)
298
+
299
+
300
+ def decode(data: object, *, library: Optional[ctypes.CDLL] = None) -> bytes:
301
+ """Decode exactly one complete, zero-delimited frame."""
302
+ raw = memoryview(data).cast("B").tobytes()
303
+ with Decoder(library) as decoder:
304
+ output, consumed, complete = decoder.feed(raw)
305
+
306
+ if not complete:
307
+ raise ValueError("incomplete COBS frame")
308
+
309
+ if consumed != len(raw):
310
+ raise ValueError("input contains data after the first COBS frame")
311
+
312
+ return output