mcap-codec-support 0.2.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,413 @@
1
+ """PyAV-based video compression and decompression backend.
2
+
3
+ All ``av`` (PyAV) usage is confined to this module. Importing this file
4
+ requires PyAV and numpy to be installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from fractions import Fraction
11
+ from io import BytesIO
12
+ from typing import TYPE_CHECKING, cast
13
+
14
+ import av
15
+ import av.error
16
+ from av import Packet, VideoFrame
17
+ from typing_extensions import Self
18
+
19
+ from mcap_codec_support.video.common import (
20
+ DecompressedFrame,
21
+ EncoderConfig,
22
+ VideoEncoderError,
23
+ build_encoder_options,
24
+ )
25
+ from mcap_codec_support.video.common import (
26
+ resolve_encoder as _resolve_encoder,
27
+ )
28
+ from mcap_codec_support.video.common import (
29
+ resolve_encoder_for_backend as _resolve_encoder_for_backend,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from av.container import InputContainer
34
+ from av.video.codeccontext import VideoCodecContext
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Encoder probing
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def test_encoder(encoder_name: str) -> bool:
43
+ """Test if an encoder is available via PyAV."""
44
+ try:
45
+ av.CodecContext.create(encoder_name, "w")
46
+ except (av.error.FFmpegError, ValueError):
47
+ return False
48
+ else:
49
+ return True
50
+
51
+
52
+ def resolve_encoder(codec: str, *, use_hardware: bool = True) -> str:
53
+ """Pick the best available encoder for *codec* using PyAV to probe."""
54
+ return _resolve_encoder(codec, test_fn=test_encoder, use_hardware=use_hardware)
55
+
56
+
57
+ def resolve_encoder_for_backend(codec: str, backend: str) -> str:
58
+ """Pick the encoder for *codec* using the specified *backend* (PyAV probe)."""
59
+ return _resolve_encoder_for_backend(codec, backend, test_fn=test_encoder)
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Image decoding (JPEG / PNG → VideoFrame)
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ class _DecoderLocal(threading.local):
68
+ """Thread-local persistent codec contexts for JPEG and PNG decoding."""
69
+
70
+ mjpeg_ctx: VideoCodecContext | None = None
71
+ png_ctx: VideoCodecContext | None = None
72
+
73
+
74
+ _decoder_local = _DecoderLocal()
75
+
76
+
77
+ def _get_mjpeg_ctx() -> VideoCodecContext:
78
+ ctx = _decoder_local.mjpeg_ctx
79
+ if ctx is None:
80
+ ctx = cast("VideoCodecContext", av.CodecContext.create("mjpeg", "r"))
81
+ ctx.open()
82
+ _decoder_local.mjpeg_ctx = ctx
83
+ return ctx
84
+
85
+
86
+ def _get_png_ctx() -> VideoCodecContext:
87
+ ctx = _decoder_local.png_ctx
88
+ if ctx is None:
89
+ ctx = av.CodecContext.create("png", "r")
90
+ ctx.open()
91
+ _decoder_local.png_ctx = ctx
92
+ return ctx
93
+
94
+
95
+ def _detect_image_format(data: bytes) -> str:
96
+ if data[:2] == b"\xff\xd8":
97
+ return "mjpeg"
98
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
99
+ return "png"
100
+ return "unknown"
101
+
102
+
103
+ def _decode_via_container(data: bytes) -> VideoFrame:
104
+ try:
105
+ with cast("InputContainer", av.open(BytesIO(data))) as container:
106
+ for frame in container.decode(video=0):
107
+ return frame
108
+ except av.error.FFmpegError as exc:
109
+ raise VideoEncoderError(f"Failed to decode compressed image: {exc}") from exc
110
+ raise VideoEncoderError("Decoder produced no frames")
111
+
112
+
113
+ def decode_compressed_frame(compressed_data: bytes) -> VideoFrame:
114
+ """Decode a compressed image (JPEG/PNG) to a VideoFrame.
115
+
116
+ Uses persistent thread-local codec contexts for performance.
117
+ """
118
+ fmt = _detect_image_format(compressed_data)
119
+ if fmt == "unknown":
120
+ return _decode_via_container(compressed_data)
121
+
122
+ ctx = _get_mjpeg_ctx() if fmt == "mjpeg" else _get_png_ctx()
123
+ try:
124
+ for frame in ctx.decode(Packet(compressed_data)):
125
+ return frame
126
+ except av.error.FFmpegError as exc:
127
+ raise VideoEncoderError(f"Failed to decode compressed image: {exc}") from exc
128
+
129
+ raise VideoEncoderError("Decoder produced no frames")
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # VideoEncoder (H.264/H.265 frame encoder)
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ class VideoEncoder:
138
+ """PyAV-based video encoder for converting images to compressed video."""
139
+
140
+ _YUV420P_COMPAT = frozenset({"yuv420p", "yuvj420p"})
141
+
142
+ def __init__(
143
+ self,
144
+ width: int,
145
+ height: int,
146
+ codec_name: str,
147
+ quality: int = 28,
148
+ target_fps: float = 30.0,
149
+ gop_size: int = 30,
150
+ *,
151
+ preset: str | None = None,
152
+ ) -> None:
153
+ self.config = EncoderConfig(width=width, height=height, codec_name=codec_name)
154
+ self._target_fps = max(target_fps, 1.0)
155
+ self._frame_index = 0
156
+ self._quality = quality
157
+ self._gop_size = gop_size
158
+ self._context: VideoCodecContext | None = None
159
+
160
+ try:
161
+ self._context = cast("VideoCodecContext", av.CodecContext.create(codec_name, "w"))
162
+ except av.error.FFmpegError as exc:
163
+ raise VideoEncoderError(f"Failed to create encoder {codec_name}: {exc}") from exc
164
+
165
+ fps_int = max(round(self._target_fps), 1)
166
+ self._context.width = width
167
+ self._context.height = height
168
+ self._context.pix_fmt = "yuv420p"
169
+ self._context.time_base = Fraction(1, fps_int)
170
+ self._context.framerate = Fraction(fps_int, 1)
171
+ self._context.gop_size = gop_size
172
+ self._context.max_b_frames = 0
173
+
174
+ options, bit_rate = build_encoder_options(codec_name, quality, width, height, preset=preset)
175
+ if bit_rate is not None:
176
+ self._context.bit_rate = bit_rate
177
+ if options:
178
+ self._context.options = options
179
+
180
+ try:
181
+ self._context.open()
182
+ except av.error.FFmpegError as exc:
183
+ self._context = None
184
+ raise VideoEncoderError(f"Failed to open encoder {codec_name}: {exc}") from exc
185
+
186
+ def close(self) -> None:
187
+ """Release the native codec context."""
188
+ if self._context is not None:
189
+ del self._context
190
+ self._context = None
191
+
192
+ def __enter__(self) -> Self:
193
+ return self
194
+
195
+ def __exit__(self, *_: object) -> None:
196
+ self.close()
197
+
198
+ def encode(self, frame: VideoFrame) -> bytes | None:
199
+ """Encode a single frame and return compressed video bytes, or None if buffered."""
200
+ needs_resize = frame.width != self.config.width or frame.height != self.config.height
201
+ needs_fmt = frame.format.name not in self._YUV420P_COMPAT
202
+ if needs_resize or needs_fmt:
203
+ frame = frame.reformat(
204
+ width=self.config.width, height=self.config.height, format=self._context.pix_fmt
205
+ )
206
+ frame.pts = self._frame_index
207
+ self._frame_index += 1
208
+
209
+ try:
210
+ packets = list(self._context.encode(frame))
211
+ except av.error.FFmpegError as exc:
212
+ raise VideoEncoderError(f"Encoding error: {exc}") from exc
213
+
214
+ if not packets:
215
+ return None
216
+ return b"".join(bytes(packet) for packet in packets)
217
+
218
+ def flush_packets(self) -> list[bytes]:
219
+ """Flush remaining buffered frames as one bytes blob per packet."""
220
+ try:
221
+ packets = list(self._context.encode(None))
222
+ except av.error.FFmpegError:
223
+ return []
224
+ return [bytes(packet) for packet in packets]
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # JpegEncoder (single-frame MJPEG)
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ class JpegEncoder:
233
+ """PyAV-based MJPEG encoder for converting individual frames to JPEG.
234
+
235
+ JPEG is intra-only, so each call to ``encode`` produces exactly one
236
+ JPEG-encoded blob with no buffering.
237
+ """
238
+
239
+ def __init__(self, width: int, height: int, quality: int = 90) -> None:
240
+ if not 1 <= quality <= 100:
241
+ raise VideoEncoderError(f"JPEG quality must be in [1, 100], got {quality}")
242
+ self.config = EncoderConfig(width=width, height=height, codec_name="mjpeg")
243
+ self._quality = quality
244
+ self._context: VideoCodecContext | None = None
245
+ try:
246
+ self._context = cast("VideoCodecContext", av.CodecContext.create("mjpeg", "w"))
247
+ except av.error.FFmpegError as exc:
248
+ raise VideoEncoderError(f"Failed to create mjpeg encoder: {exc}") from exc
249
+
250
+ self._context.width = width
251
+ self._context.height = height
252
+ self._context.pix_fmt = "yuvj420p"
253
+ self._context.time_base = Fraction(1, 1000)
254
+ # PyAV's mjpeg encoder maps q:v 1..31 (lower = better). Convert from 1..100.
255
+ qv = max(1, min(31, 32 - quality * 31 // 100))
256
+ self._context.options = {"q:v": str(qv)}
257
+
258
+ try:
259
+ self._context.open()
260
+ except av.error.FFmpegError as exc:
261
+ self._context = None
262
+ raise VideoEncoderError(f"Failed to open mjpeg encoder: {exc}") from exc
263
+
264
+ self._frame_index = 0
265
+
266
+ def encode(self, frame: VideoFrame) -> bytes:
267
+ """Encode a single frame to a complete JPEG blob."""
268
+ if (
269
+ frame.width != self.config.width
270
+ or frame.height != self.config.height
271
+ or frame.format.name != "yuvj420p"
272
+ ):
273
+ frame = frame.reformat(
274
+ width=self.config.width, height=self.config.height, format="yuvj420p"
275
+ )
276
+ frame.pts = self._frame_index
277
+ self._frame_index += 1
278
+ try:
279
+ packets = list(self._context.encode(frame))
280
+ except av.error.FFmpegError as exc:
281
+ raise VideoEncoderError(f"JPEG encoding error: {exc}") from exc
282
+ if not packets:
283
+ raise VideoEncoderError("MJPEG encoder produced no output")
284
+ return b"".join(bytes(p) for p in packets)
285
+
286
+ def close(self) -> None:
287
+ """Release the native codec context."""
288
+ if self._context is not None:
289
+ del self._context
290
+ self._context = None
291
+
292
+ def __enter__(self) -> Self:
293
+ return self
294
+
295
+ def __exit__(self, *_: object) -> None:
296
+ self.close()
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # PyAVVideoDecompressor (H.264/H.265 → Image)
301
+ # ---------------------------------------------------------------------------
302
+
303
+
304
+ class PyAVVideoDecompressor:
305
+ """Decompresses H.264/H.265 video to JPEG or raw RGB using PyAV.
306
+
307
+ Implements ``VideoDecompressorProtocol``.
308
+ """
309
+
310
+ def __init__(
311
+ self,
312
+ video_format: str = "compressed",
313
+ jpeg_quality: int = 90,
314
+ ) -> None:
315
+ self._video_format = video_format
316
+ self._jpeg_quality = jpeg_quality
317
+ self._decoder: VideoCodecContext | None = None
318
+ self._jpeg_encoder: VideoCodecContext | None = None
319
+
320
+ def _ensure_decoder(self, codec: str) -> VideoCodecContext:
321
+ if self._decoder is not None:
322
+ return self._decoder
323
+ codec_name = "h264" if codec == "h264" else "hevc"
324
+ self._decoder = av.CodecContext.create(codec_name, "r")
325
+ self._decoder.open()
326
+ return self._decoder
327
+
328
+ def _ensure_jpeg_encoder(self, width: int, height: int) -> VideoCodecContext:
329
+ if self._jpeg_encoder is not None:
330
+ return self._jpeg_encoder
331
+ self._jpeg_encoder = cast("VideoCodecContext", av.CodecContext.create("mjpeg", "w"))
332
+ self._jpeg_encoder.width = width
333
+ self._jpeg_encoder.height = height
334
+ self._jpeg_encoder.pix_fmt = "yuvj420p"
335
+ self._jpeg_encoder.time_base = Fraction(1, 1000)
336
+ self._jpeg_encoder.options = {
337
+ "q:v": str(max(1, 31 - self._jpeg_quality * 31 // 100)),
338
+ }
339
+ self._jpeg_encoder.open()
340
+ return self._jpeg_encoder
341
+
342
+ def decompress(self, video_data: bytes, codec: str) -> DecompressedFrame | None:
343
+ decoder = self._ensure_decoder(codec)
344
+ frames = decoder.decode(Packet(video_data))
345
+ if not frames:
346
+ return None
347
+ frame = frames[-1]
348
+
349
+ if self._video_format == "compressed":
350
+ encoder = self._ensure_jpeg_encoder(frame.width, frame.height)
351
+ reformatted = frame.reformat(format="yuvj420p")
352
+ reformatted.pts = 0
353
+ packets = encoder.encode(reformatted)
354
+ jpeg_data = b"".join(bytes(p) for p in packets)
355
+ return DecompressedFrame(
356
+ data=jpeg_data,
357
+ width=frame.width,
358
+ height=frame.height,
359
+ is_jpeg=True,
360
+ )
361
+
362
+ rgb_frame = frame.reformat(format="rgb24")
363
+ raw_data = rgb_frame.to_ndarray().tobytes()
364
+ return DecompressedFrame(
365
+ data=raw_data,
366
+ width=rgb_frame.width,
367
+ height=rgb_frame.height,
368
+ is_jpeg=False,
369
+ )
370
+
371
+ def close(self) -> None:
372
+ """Release native codec contexts."""
373
+ self._decoder = None
374
+ self._jpeg_encoder = None
375
+
376
+ def __enter__(self) -> Self:
377
+ return self
378
+
379
+ def __exit__(self, *_: object) -> None:
380
+ self.close()
381
+
382
+ def flush(self) -> list[DecompressedFrame]:
383
+ if self._decoder is None:
384
+ return []
385
+ frames = self._decoder.decode(None)
386
+ results: list[DecompressedFrame] = []
387
+ for frame in frames:
388
+ if self._video_format == "compressed":
389
+ encoder = self._ensure_jpeg_encoder(frame.width, frame.height)
390
+ reformatted = frame.reformat(format="yuvj420p")
391
+ reformatted.pts = 0
392
+ packets = encoder.encode(reformatted)
393
+ jpeg_data = b"".join(bytes(p) for p in packets)
394
+ results.append(
395
+ DecompressedFrame(
396
+ data=jpeg_data,
397
+ width=frame.width,
398
+ height=frame.height,
399
+ is_jpeg=True,
400
+ )
401
+ )
402
+ else:
403
+ rgb_frame = frame.reformat(format="rgb24")
404
+ raw_data = rgb_frame.to_ndarray().tobytes()
405
+ results.append(
406
+ DecompressedFrame(
407
+ data=raw_data,
408
+ width=rgb_frame.width,
409
+ height=rgb_frame.height,
410
+ is_jpeg=False,
411
+ )
412
+ )
413
+ return results
@@ -0,0 +1,52 @@
1
+ """Video-related ROS schema names and definitions."""
2
+
3
+ COMPRESSED_VIDEO_SCHEMA = "foxglove_msgs/msg/CompressedVideo"
4
+
5
+ # Canonical short schema names, matching the normal form used by CLI exporters.
6
+ COMPRESSED_SCHEMAS = {"sensor_msgs/CompressedImage"}
7
+ RAW_SCHEMAS = {"sensor_msgs/Image"}
8
+ IMAGE_SCHEMAS = COMPRESSED_SCHEMAS | RAW_SCHEMAS
9
+
10
+ FOXGLOVE_COMPRESSED_VIDEO = """builtin_interfaces/Time timestamp
11
+ string frame_id
12
+ uint8[] data
13
+ string format
14
+
15
+ ================================================================================
16
+ MSG: builtin_interfaces/Time
17
+ int32 sec
18
+ uint32 nanosec"""
19
+
20
+ COMPRESSED_IMAGE = """\
21
+ std_msgs/Header header
22
+ string format
23
+ uint8[] data
24
+
25
+ ================================================================================
26
+ MSG: std_msgs/Header
27
+ builtin_interfaces/Time stamp
28
+ string frame_id
29
+
30
+ ================================================================================
31
+ MSG: builtin_interfaces/Time
32
+ int32 sec
33
+ uint32 nanosec"""
34
+
35
+ IMAGE = """\
36
+ std_msgs/Header header
37
+ uint32 height
38
+ uint32 width
39
+ string encoding
40
+ uint8 is_bigendian
41
+ uint32 step
42
+ uint8[] data
43
+
44
+ ================================================================================
45
+ MSG: std_msgs/Header
46
+ builtin_interfaces/Time stamp
47
+ string frame_id
48
+
49
+ ================================================================================
50
+ MSG: builtin_interfaces/Time
51
+ int32 sec
52
+ uint32 nanosec"""
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.3
2
+ Name: mcap-codec-support
3
+ Version: 0.2.0
4
+ Summary: Reusable MCAP encoder and decoder factories for robotics codecs
5
+ Keywords: mcap,robotics,factories,video,pointcloud
6
+ Author: Marko Bausch
7
+ License: GPL-3.0
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Classifier: Typing :: Typed
20
+ Requires-Dist: typing-extensions>=4.15.0
21
+ Requires-Dist: mcap-codec-support[ros2,video,pointcloud,image,draco] ; extra == 'all'
22
+ Requires-Dist: dracopy>=1.7.0 ; extra == 'draco'
23
+ Requires-Dist: mcap-ros2-support-fast ; extra == 'draco'
24
+ Requires-Dist: numpy>=1.24.0 ; extra == 'draco'
25
+ Requires-Dist: pointcloud2 ; extra == 'draco'
26
+ Requires-Dist: mcap-codec-support[video] ; extra == 'image'
27
+ Requires-Dist: imagecodecs>=2024.1.1 ; extra == 'image'
28
+ Requires-Dist: mcap-ros2-support-fast ; extra == 'pointcloud'
29
+ Requires-Dist: pureini ; extra == 'pointcloud'
30
+ Requires-Dist: numpy>=1.24.0 ; extra == 'pointcloud'
31
+ Requires-Dist: pointcloud2 ; extra == 'pointcloud'
32
+ Requires-Dist: mcap-ros2-support-fast ; extra == 'ros2'
33
+ Requires-Dist: av>=12.0.0 ; extra == 'video'
34
+ Requires-Dist: numpy>=1.24.0 ; extra == 'video'
35
+ Requires-Python: >=3.10
36
+ Project-URL: Homepage, https://github.com/mrkbac/robotic-tools
37
+ Project-URL: Issues, https://github.com/mrkbac/robotic-tools/issues
38
+ Project-URL: Repository, https://github.com/mrkbac/robotic-tools
39
+ Provides-Extra: all
40
+ Provides-Extra: draco
41
+ Provides-Extra: image
42
+ Provides-Extra: pointcloud
43
+ Provides-Extra: ros2
44
+ Provides-Extra: video
45
+ Description-Content-Type: text/markdown
46
+
47
+ # mcap-codec-support
48
+
49
+ Reusable MCAP encoder and decoder factories used by `pymcap-cli`.
50
+
51
+ The package is intentionally factory-focused. ROS2 CDR support stays in
52
+ `mcap-ros2-support-fast`; this package composes with it when the relevant
53
+ factories are constructed.
@@ -0,0 +1,20 @@
1
+ mcap_codec_support/__init__.py,sha256=CtGwHYyaC1Co5xkoo1CGgwnesTaqDceYMpsZZTnwKKo,51
2
+ mcap_codec_support/_messages.py,sha256=WYEWdOGZwUa-MXS0tNgtwg9MoGJm7o62-YxLdOuaS7Q,1070
3
+ mcap_codec_support/_protocols.py,sha256=kkn2-g6qTf8eM2RFy6eHLjW03HZg9KZsj-cmvtxTQ9c,2506
4
+ mcap_codec_support/_schemas.py,sha256=vd6BsBoqhABlUqrYi6AnQUGtYxP-Tq89bUlAH4rPjQw,633
5
+ mcap_codec_support/pointcloud/__init__.py,sha256=CT2jRZxADInTHMfp6Txdx90eZvF19aZryicUAOeEyPg,1458
6
+ mcap_codec_support/pointcloud/compression.py,sha256=wt4F3fPuAMMy6374lT314JTl20DpDz7L5jPaOsK2e0o,11854
7
+ mcap_codec_support/pointcloud/factories.py,sha256=JO-5DHxdvUW98NLZTJGWUpJvEbquw6_1RdezZ5d54SI,16100
8
+ mcap_codec_support/pointcloud/schemas.py,sha256=w-Jvu6Lh8WdBtF9XRr9gZHAPQzvXvAvLn83nnu4Q2Dk,2759
9
+ mcap_codec_support/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ mcap_codec_support/video/__init__.py,sha256=47IOYwMmrzZESMAxlHbrkisS2UrB9LFr-KeIEH1eA6o,1449
11
+ mcap_codec_support/video/common.py,sha256=ZBD76iy7qiR2W4-OACb4h48QFc0ub85TA2BZca2F5KM,8334
12
+ mcap_codec_support/video/compression.py,sha256=Ke59Zuc0BzEf5nx-WMMjylKTfpYNj1Bu-ekPOCXB1fI,9317
13
+ mcap_codec_support/video/factories.py,sha256=Ei-Q_HX2LA48BpKrsTSg049DuFM276gQkricgiMUXRw,4457
14
+ mcap_codec_support/video/ffmpeg.py,sha256=Elbpvs5DWhgAg--OP1GurNYPBzSHdB7C1j6o3PaQ_LY,31134
15
+ mcap_codec_support/video/file_writer.py,sha256=DvtTc4oXQgJaU-VrUHwl4zNF13lRCNYtiSRcHrbQ2Q0,13643
16
+ mcap_codec_support/video/pyav.py,sha256=3wiDtos8G1YnknB4k4pQ10jyN0SMUFifXEv5ZD3VI0Y,14332
17
+ mcap_codec_support/video/schemas.py,sha256=i_oXYM5JpJ44FzDrVeewg23yGkqM7-37_AHKvx0bXXY,1359
18
+ mcap_codec_support-0.2.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
19
+ mcap_codec_support-0.2.0.dist-info/METADATA,sha256=5_B86oYMqD0sXG4Oe1fERs7sBjrvwuhKzF-dBmEbRxY,2271
20
+ mcap_codec_support-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any