init
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
71
README.md
Normal file
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# py-pwr
|
||||
|
||||
Minimal Python client for wharf/pwr files. It parses the wire format,
|
||||
protobuf messages, and can apply patch ops when you provide file index
|
||||
to path mappings.
|
||||
|
||||
## Features
|
||||
- Read `.pwr`, `.pws`, `.pwm`, `.pww` files.
|
||||
- Iterate sync ops and bsdiff controls.
|
||||
- Apply patches for RSYNC and BSDIFF series using target/output file lists.
|
||||
- Optional decompression for brotli/zstd if the modules are installed.
|
||||
|
||||
## Limitations
|
||||
- TLC container decoding is not included. You can pass a decoder to
|
||||
`PatchReader`/`SignatureReader` to parse the raw container bytes.
|
||||
|
||||
## Usage
|
||||
|
||||
Patch inspection:
|
||||
|
||||
```python
|
||||
from pwr import PatchReader
|
||||
|
||||
with PatchReader.open("update.pwr") as reader:
|
||||
for entry in reader.iter_file_entries():
|
||||
if entry.is_rsync():
|
||||
for op in entry.sync_ops:
|
||||
pass
|
||||
elif entry.is_bsdiff():
|
||||
for ctrl in entry.bsdiff_controls:
|
||||
pass
|
||||
```
|
||||
|
||||
Apply a patch (requires file index mappings):
|
||||
|
||||
```python
|
||||
from pwr import PatchReader, apply_patch
|
||||
|
||||
target_paths = [
|
||||
"old/file0.bin",
|
||||
"old/file1.bin",
|
||||
]
|
||||
|
||||
output_paths = [
|
||||
"new/file0.bin",
|
||||
"new/file1.bin",
|
||||
]
|
||||
|
||||
with PatchReader.open("update.pwr") as reader:
|
||||
apply_patch(reader, target_paths, output_paths)
|
||||
```
|
||||
|
||||
Apply to folders (uses embedded TLC container paths):
|
||||
|
||||
```python
|
||||
from pwr import apply_patch_to_folders
|
||||
|
||||
apply_patch_to_folders("update.pwr", "old_folder", "new_folder")
|
||||
```
|
||||
|
||||
Quick inspect:
|
||||
|
||||
```bash
|
||||
python py-pwr/main.py path/to/file.pwr
|
||||
```
|
||||
|
||||
Apply via CLI:
|
||||
|
||||
```bash
|
||||
python py-pwr/main.py apply update.pwr /path/to/old_folder /path/to/new_folder
|
||||
```
|
||||
131
main.py
Normal file
131
main.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from pwr import (
|
||||
MANIFEST_MAGIC,
|
||||
PATCH_MAGIC,
|
||||
SIGNATURE_MAGIC,
|
||||
WOUNDS_MAGIC,
|
||||
ManifestReader,
|
||||
PatchReader,
|
||||
SignatureReader,
|
||||
SyncOpType,
|
||||
WoundsReader,
|
||||
apply_patch_to_folders,
|
||||
)
|
||||
from pwr.wire import read_magic
|
||||
|
||||
|
||||
def _inspect_patch(path: str) -> None:
|
||||
files = 0
|
||||
rsync_files = 0
|
||||
bsdiff_files = 0
|
||||
ops = 0
|
||||
data_bytes = 0
|
||||
controls = 0
|
||||
|
||||
with PatchReader.open(path) as reader:
|
||||
compression = reader.header.compression.algorithm if reader.header and reader.header.compression else None
|
||||
for entry in reader.iter_file_entries():
|
||||
files += 1
|
||||
if entry.is_rsync():
|
||||
rsync_files += 1
|
||||
for op in entry.sync_ops or []:
|
||||
ops += 1
|
||||
if op.type == SyncOpType.DATA:
|
||||
data_bytes += len(op.data or b"")
|
||||
elif entry.is_bsdiff():
|
||||
bsdiff_files += 1
|
||||
for ctrl in entry.bsdiff_controls or []:
|
||||
controls += 1
|
||||
data_bytes += len(ctrl.add or b"") + len(ctrl.copy or b"")
|
||||
|
||||
print("patch")
|
||||
print(f" files: {files} (rsync={rsync_files}, bsdiff={bsdiff_files})")
|
||||
print(f" ops: {ops}, controls: {controls}")
|
||||
print(f" data bytes: {data_bytes}")
|
||||
print(f" compression: {compression}")
|
||||
|
||||
|
||||
def _inspect_signature(path: str) -> None:
|
||||
blocks = 0
|
||||
with SignatureReader.open(path) as reader:
|
||||
compression = reader.header.compression.algorithm if reader.header and reader.header.compression else None
|
||||
for _ in reader.iter_block_hashes():
|
||||
blocks += 1
|
||||
print("signature")
|
||||
print(f" block hashes: {blocks}")
|
||||
print(f" compression: {compression}")
|
||||
|
||||
|
||||
def _inspect_manifest(path: str) -> None:
|
||||
hashes = 0
|
||||
with ManifestReader.open(path) as reader:
|
||||
compression = reader.header.compression.algorithm if reader.header and reader.header.compression else None
|
||||
for _ in reader.iter_block_hashes():
|
||||
hashes += 1
|
||||
print("manifest")
|
||||
print(f" block hashes: {hashes}")
|
||||
print(f" compression: {compression}")
|
||||
|
||||
|
||||
def _inspect_wounds(path: str) -> None:
|
||||
wounds = 0
|
||||
with WoundsReader.open(path) as reader:
|
||||
for _ in reader.iter_wounds():
|
||||
wounds += 1
|
||||
print("wounds")
|
||||
print(f" wounds: {wounds}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Minimal wharf/pwr inspector")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
inspect_parser = subparsers.add_parser("inspect", help="inspect a wharf file")
|
||||
inspect_parser.add_argument("path", help="path to .pwr/.pws/.pwm/.pww file")
|
||||
|
||||
apply_parser = subparsers.add_parser("apply", help="apply a .pwr patch to folders")
|
||||
apply_parser.add_argument("patch", help="path to .pwr file")
|
||||
apply_parser.add_argument("target", help="folder with the old version")
|
||||
apply_parser.add_argument("output", help="folder to write the new version")
|
||||
|
||||
parser.add_argument(
|
||||
"path",
|
||||
nargs="?",
|
||||
help="path to .pwr/.pws/.pwm/.pww file (shorthand for inspect)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "apply":
|
||||
apply_patch_to_folders(args.patch, args.target, args.output)
|
||||
return
|
||||
|
||||
path = None
|
||||
if args.command == "inspect":
|
||||
path = args.path
|
||||
elif args.path:
|
||||
path = args.path
|
||||
|
||||
if not path:
|
||||
parser.print_help(sys.stderr)
|
||||
return
|
||||
|
||||
with open(path, "rb") as handle:
|
||||
magic = read_magic(handle)
|
||||
|
||||
if magic == PATCH_MAGIC:
|
||||
_inspect_patch(path)
|
||||
elif magic == SIGNATURE_MAGIC:
|
||||
_inspect_signature(path)
|
||||
elif magic == MANIFEST_MAGIC:
|
||||
_inspect_manifest(path)
|
||||
elif magic == WOUNDS_MAGIC:
|
||||
_inspect_wounds(path)
|
||||
else:
|
||||
print(f"unknown magic: {magic}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
97
pwr/__init__.py
Normal file
97
pwr/__init__.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from .apply import (
|
||||
FilePool,
|
||||
PatchApplyError,
|
||||
apply_bsdiff_controls,
|
||||
apply_patch,
|
||||
apply_patch_to_folders,
|
||||
apply_rsync_ops,
|
||||
)
|
||||
from .compression import CompressionError
|
||||
from .formats import FilePatch, ManifestReader, PatchReader, SignatureReader, WoundsReader
|
||||
from .proto import (
|
||||
BlockHash,
|
||||
BsdiffHeader,
|
||||
CompressionAlgorithm,
|
||||
CompressionSettings,
|
||||
Control,
|
||||
HashAlgorithm,
|
||||
ManifestBlockHash,
|
||||
ManifestHeader,
|
||||
OverlayHeader,
|
||||
OverlayOp,
|
||||
OverlayOpType,
|
||||
PatchHeader,
|
||||
Sample,
|
||||
SignatureHeader,
|
||||
SyncHeader,
|
||||
SyncHeaderType,
|
||||
SyncOp,
|
||||
SyncOpType,
|
||||
TlcContainer,
|
||||
TlcDir,
|
||||
TlcFile,
|
||||
TlcSymlink,
|
||||
Wound,
|
||||
WoundKind,
|
||||
WoundsHeader,
|
||||
)
|
||||
from .wire import (
|
||||
BLOCK_SIZE,
|
||||
MANIFEST_MAGIC,
|
||||
PATCH_MAGIC,
|
||||
SIGNATURE_MAGIC,
|
||||
WOUNDS_MAGIC,
|
||||
ZIP_INDEX_MAGIC,
|
||||
WireError,
|
||||
WireReader,
|
||||
WireWriter,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FilePool",
|
||||
"PatchApplyError",
|
||||
"apply_bsdiff_controls",
|
||||
"apply_patch",
|
||||
"apply_patch_to_folders",
|
||||
"apply_rsync_ops",
|
||||
"CompressionError",
|
||||
"FilePatch",
|
||||
"ManifestReader",
|
||||
"PatchReader",
|
||||
"SignatureReader",
|
||||
"WoundsReader",
|
||||
"BlockHash",
|
||||
"BsdiffHeader",
|
||||
"CompressionAlgorithm",
|
||||
"CompressionSettings",
|
||||
"Control",
|
||||
"HashAlgorithm",
|
||||
"ManifestBlockHash",
|
||||
"ManifestHeader",
|
||||
"OverlayHeader",
|
||||
"OverlayOp",
|
||||
"OverlayOpType",
|
||||
"PatchHeader",
|
||||
"Sample",
|
||||
"SignatureHeader",
|
||||
"SyncHeader",
|
||||
"SyncHeaderType",
|
||||
"SyncOp",
|
||||
"SyncOpType",
|
||||
"TlcContainer",
|
||||
"TlcDir",
|
||||
"TlcFile",
|
||||
"TlcSymlink",
|
||||
"Wound",
|
||||
"WoundKind",
|
||||
"WoundsHeader",
|
||||
"BLOCK_SIZE",
|
||||
"MANIFEST_MAGIC",
|
||||
"PATCH_MAGIC",
|
||||
"SIGNATURE_MAGIC",
|
||||
"WOUNDS_MAGIC",
|
||||
"ZIP_INDEX_MAGIC",
|
||||
"WireError",
|
||||
"WireReader",
|
||||
"WireWriter",
|
||||
]
|
||||
198
pwr/apply.py
Normal file
198
pwr/apply.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import BinaryIO
|
||||
|
||||
from .formats import FilePatch, PatchReader
|
||||
from .proto import Control, SyncOp, SyncOpType
|
||||
from .proto import TlcContainer, TlcDir, TlcFile, TlcSymlink
|
||||
from .wire import BLOCK_SIZE
|
||||
|
||||
|
||||
class PatchApplyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_MODE_MASK = 0o7777
|
||||
|
||||
|
||||
class FilePool:
|
||||
def __init__(self, paths: list[str]):
|
||||
self._paths = list(paths)
|
||||
self._handles: dict[int, BinaryIO] = {}
|
||||
|
||||
def open(self, index: int) -> BinaryIO:
|
||||
if index < 0 or index >= len(self._paths):
|
||||
raise PatchApplyError(f"file index out of range: {index}")
|
||||
handle = self._handles.get(index)
|
||||
if handle is None:
|
||||
handle = open(self._paths[index], "rb")
|
||||
self._handles[index] = handle
|
||||
return handle
|
||||
|
||||
def size(self, index: int) -> int:
|
||||
if index < 0 or index >= len(self._paths):
|
||||
raise PatchApplyError(f"file index out of range: {index}")
|
||||
return os.path.getsize(self._paths[index])
|
||||
|
||||
def close(self) -> None:
|
||||
for handle in self._handles.values():
|
||||
handle.close()
|
||||
self._handles.clear()
|
||||
|
||||
|
||||
def _copy_range(dst: BinaryIO, src: BinaryIO, length: int, buffer_size: int = 32 * 1024) -> None:
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
chunk = src.read(min(buffer_size, remaining))
|
||||
if not chunk:
|
||||
raise PatchApplyError("unexpected EOF while copying block range")
|
||||
dst.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
|
||||
def apply_rsync_ops(ops: list[SyncOp], target_pool: FilePool, output: BinaryIO) -> None:
|
||||
for op in ops:
|
||||
if op.type == SyncOpType.DATA:
|
||||
output.write(op.data or b"")
|
||||
continue
|
||||
|
||||
if op.type != SyncOpType.BLOCK_RANGE:
|
||||
raise PatchApplyError(f"unsupported sync op type: {op.type}")
|
||||
|
||||
if op.file_index is None or op.block_index is None or op.block_span is None:
|
||||
raise PatchApplyError("missing fields in block range op")
|
||||
if op.block_span <= 0:
|
||||
raise PatchApplyError("invalid block span in block range op")
|
||||
|
||||
file_size = target_pool.size(op.file_index)
|
||||
last_block_index = op.block_index + op.block_span - 1
|
||||
last_block_size = BLOCK_SIZE
|
||||
if BLOCK_SIZE * (last_block_index + 1) > file_size:
|
||||
last_block_size = file_size % BLOCK_SIZE
|
||||
op_size = (op.block_span - 1) * BLOCK_SIZE + last_block_size
|
||||
|
||||
src = target_pool.open(op.file_index)
|
||||
src.seek(op.block_index * BLOCK_SIZE)
|
||||
_copy_range(output, src, op_size)
|
||||
|
||||
|
||||
def apply_bsdiff_controls(controls: list[Control], old: BinaryIO, output: BinaryIO) -> None:
|
||||
old_offset = 0
|
||||
for ctrl in controls:
|
||||
if ctrl.eof:
|
||||
break
|
||||
|
||||
add = ctrl.add or b""
|
||||
copy = ctrl.copy or b""
|
||||
seek = ctrl.seek or 0
|
||||
|
||||
if add:
|
||||
old.seek(old_offset)
|
||||
old_chunk = old.read(len(add))
|
||||
if len(old_chunk) != len(add):
|
||||
raise PatchApplyError("unexpected EOF while reading bsdiff add data")
|
||||
out = bytes(((a + b) & 0xFF) for a, b in zip(old_chunk, add))
|
||||
output.write(out)
|
||||
old_offset += len(add)
|
||||
|
||||
if copy:
|
||||
output.write(copy)
|
||||
|
||||
old_offset += seek
|
||||
|
||||
|
||||
def _ensure_parent(path: str) -> None:
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
|
||||
def apply_patch(patch_reader: PatchReader, target_paths: list[str], output_paths: list[str]) -> None:
|
||||
pool = FilePool(target_paths)
|
||||
try:
|
||||
for entry in patch_reader.iter_file_entries():
|
||||
if entry.sync_header.file_index is None:
|
||||
raise PatchApplyError("missing file_index in sync header")
|
||||
out_index = entry.sync_header.file_index
|
||||
if out_index < 0 or out_index >= len(output_paths):
|
||||
raise PatchApplyError(f"output index out of range: {out_index}")
|
||||
|
||||
out_path = output_paths[out_index]
|
||||
_ensure_parent(out_path)
|
||||
|
||||
with open(out_path, "wb") as out:
|
||||
if entry.is_rsync():
|
||||
if entry.sync_ops is None:
|
||||
raise PatchApplyError("missing rsync ops")
|
||||
apply_rsync_ops(entry.sync_ops, pool, out)
|
||||
elif entry.is_bsdiff():
|
||||
if entry.bsdiff_header is None or entry.bsdiff_controls is None:
|
||||
raise PatchApplyError("missing bsdiff data")
|
||||
if entry.bsdiff_header.target_index is None:
|
||||
raise PatchApplyError("missing target_index in bsdiff header")
|
||||
old = pool.open(entry.bsdiff_header.target_index)
|
||||
apply_bsdiff_controls(entry.bsdiff_controls, old, out)
|
||||
else:
|
||||
raise PatchApplyError("unknown file patch type")
|
||||
finally:
|
||||
pool.close()
|
||||
|
||||
|
||||
def _container_file_paths(container: TlcContainer, root: str) -> list[str]:
|
||||
paths = []
|
||||
for f in container.files:
|
||||
if f.path is None:
|
||||
raise PatchApplyError("container file missing path")
|
||||
paths.append(os.path.join(root, f.path))
|
||||
return paths
|
||||
|
||||
|
||||
def _ensure_dirs(container: TlcContainer, root: str) -> None:
|
||||
for d in container.dirs:
|
||||
if d.path is None:
|
||||
continue
|
||||
path = os.path.join(root, d.path)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
if d.mode is not None:
|
||||
mode = int(d.mode) & _MODE_MASK
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_symlinks(container: TlcContainer, root: str) -> None:
|
||||
for s in container.symlinks:
|
||||
if s.path is None or s.dest is None:
|
||||
continue
|
||||
path = os.path.join(root, s.path)
|
||||
if os.path.lexists(path):
|
||||
continue
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
try:
|
||||
os.symlink(s.dest, path)
|
||||
except (AttributeError, OSError):
|
||||
continue
|
||||
|
||||
|
||||
def apply_patch_to_folders(patch_path: str, target_root: str, output_root: str) -> None:
|
||||
with PatchReader.open(
|
||||
patch_path,
|
||||
container_decoder=(TlcContainer.from_bytes, TlcContainer.from_bytes),
|
||||
) as reader:
|
||||
if reader.target_container is None or reader.source_container is None:
|
||||
raise PatchApplyError("missing containers in patch")
|
||||
|
||||
target_container: TlcContainer = reader.target_container
|
||||
source_container: TlcContainer = reader.source_container
|
||||
|
||||
_ensure_dirs(source_container, output_root)
|
||||
_ensure_symlinks(source_container, output_root)
|
||||
|
||||
target_paths = _container_file_paths(target_container, target_root)
|
||||
output_paths = _container_file_paths(source_container, output_root)
|
||||
|
||||
apply_patch(reader, target_paths, output_paths)
|
||||
158
pwr/compression.py
Normal file
158
pwr/compression.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import gzip
|
||||
from typing import BinaryIO
|
||||
|
||||
from .proto import CompressionAlgorithm, CompressionSettings
|
||||
|
||||
|
||||
class CompressionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_algorithm(algorithm: CompressionAlgorithm | int | None) -> CompressionAlgorithm:
|
||||
if algorithm is None:
|
||||
return CompressionAlgorithm.NONE
|
||||
if isinstance(algorithm, CompressionAlgorithm):
|
||||
return algorithm
|
||||
try:
|
||||
return CompressionAlgorithm(int(algorithm))
|
||||
except ValueError as exc:
|
||||
raise CompressionError(f"unknown compression algorithm: {algorithm}") from exc
|
||||
|
||||
|
||||
def _brotli_module():
|
||||
try:
|
||||
import brotli # type: ignore
|
||||
|
||||
return brotli
|
||||
except ImportError:
|
||||
try:
|
||||
import brotlicffi as brotli # type: ignore
|
||||
|
||||
return brotli
|
||||
except ImportError as exc:
|
||||
raise CompressionError("brotli module not available") from exc
|
||||
|
||||
|
||||
class _BrotliReader(io.RawIOBase):
|
||||
def __init__(self, raw: BinaryIO):
|
||||
self._raw = raw
|
||||
brotli = _brotli_module()
|
||||
self._decompressor = brotli.Decompressor()
|
||||
self._buffer = bytearray()
|
||||
self._eof = False
|
||||
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def _finish(self) -> bytes:
|
||||
if hasattr(self._decompressor, "finish"):
|
||||
return self._decompressor.finish()
|
||||
if hasattr(self._decompressor, "flush"):
|
||||
return self._decompressor.flush()
|
||||
return b""
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if size == 0:
|
||||
return b""
|
||||
|
||||
while not self._eof and (size < 0 or len(self._buffer) < size):
|
||||
chunk = self._raw.read(8192)
|
||||
if not chunk:
|
||||
self._buffer.extend(self._finish())
|
||||
self._eof = True
|
||||
break
|
||||
self._buffer.extend(self._decompressor.process(chunk))
|
||||
|
||||
if size < 0:
|
||||
data = bytes(self._buffer)
|
||||
self._buffer.clear()
|
||||
return data
|
||||
|
||||
data = bytes(self._buffer[:size])
|
||||
del self._buffer[:size]
|
||||
return data
|
||||
|
||||
def readinto(self, b) -> int:
|
||||
data = self.read(len(b))
|
||||
n = len(data)
|
||||
b[:n] = data
|
||||
return n
|
||||
|
||||
def close(self) -> None:
|
||||
if self.closed:
|
||||
return
|
||||
try:
|
||||
if hasattr(self._raw, "close"):
|
||||
self._raw.close()
|
||||
finally:
|
||||
super().close()
|
||||
|
||||
|
||||
class _BrotliWriter(io.RawIOBase):
|
||||
def __init__(self, raw: BinaryIO, quality: int | None):
|
||||
self._raw = raw
|
||||
brotli = _brotli_module()
|
||||
kwargs = {}
|
||||
if quality is not None:
|
||||
kwargs["quality"] = int(quality)
|
||||
self._compressor = brotli.Compressor(**kwargs)
|
||||
|
||||
def writable(self) -> bool:
|
||||
return True
|
||||
|
||||
def write(self, b: bytes) -> int:
|
||||
out = self._compressor.process(b)
|
||||
self._raw.write(out)
|
||||
return len(b)
|
||||
|
||||
def close(self) -> None:
|
||||
if self.closed:
|
||||
return
|
||||
try:
|
||||
tail = self._compressor.finish()
|
||||
if tail:
|
||||
self._raw.write(tail)
|
||||
if hasattr(self._raw, "close"):
|
||||
self._raw.close()
|
||||
finally:
|
||||
super().close()
|
||||
|
||||
|
||||
def open_decompressed_reader(stream: BinaryIO, compression: CompressionSettings | None) -> BinaryIO:
|
||||
algorithm = _normalize_algorithm(compression.algorithm if compression else None)
|
||||
if algorithm == CompressionAlgorithm.NONE:
|
||||
return stream
|
||||
if algorithm == CompressionAlgorithm.GZIP:
|
||||
return gzip.GzipFile(fileobj=stream, mode="rb")
|
||||
if algorithm == CompressionAlgorithm.BROTLI:
|
||||
return io.BufferedReader(_BrotliReader(stream))
|
||||
if algorithm == CompressionAlgorithm.ZSTD:
|
||||
try:
|
||||
import zstandard as zstd # type: ignore
|
||||
except ImportError as exc:
|
||||
raise CompressionError("zstandard module not available") from exc
|
||||
return zstd.ZstdDecompressor().stream_reader(stream)
|
||||
raise CompressionError(f"unsupported compression algorithm: {algorithm}")
|
||||
|
||||
|
||||
def open_compressed_writer(stream: BinaryIO, compression: CompressionSettings | None) -> BinaryIO:
|
||||
algorithm = _normalize_algorithm(compression.algorithm if compression else None)
|
||||
quality = compression.quality if compression else None
|
||||
if algorithm == CompressionAlgorithm.NONE:
|
||||
return stream
|
||||
if algorithm == CompressionAlgorithm.GZIP:
|
||||
level = 9 if quality is None else int(quality)
|
||||
return gzip.GzipFile(fileobj=stream, mode="wb", compresslevel=level)
|
||||
if algorithm == CompressionAlgorithm.BROTLI:
|
||||
return io.BufferedWriter(_BrotliWriter(stream, quality))
|
||||
if algorithm == CompressionAlgorithm.ZSTD:
|
||||
try:
|
||||
import zstandard as zstd # type: ignore
|
||||
except ImportError as exc:
|
||||
raise CompressionError("zstandard module not available") from exc
|
||||
level = 3 if quality is None else int(quality)
|
||||
return zstd.ZstdCompressor(level=level).stream_writer(stream)
|
||||
raise CompressionError(f"unsupported compression algorithm: {algorithm}")
|
||||
286
pwr/formats.py
Normal file
286
pwr/formats.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
from .compression import open_decompressed_reader
|
||||
from .proto import (
|
||||
BlockHash,
|
||||
BsdiffHeader,
|
||||
Control,
|
||||
ManifestBlockHash,
|
||||
ManifestHeader,
|
||||
PatchHeader,
|
||||
SignatureHeader,
|
||||
SyncHeader,
|
||||
SyncHeaderType,
|
||||
SyncOp,
|
||||
SyncOpType,
|
||||
Wound,
|
||||
WoundsHeader,
|
||||
)
|
||||
from .wire import (
|
||||
MANIFEST_MAGIC,
|
||||
PATCH_MAGIC,
|
||||
SIGNATURE_MAGIC,
|
||||
WOUNDS_MAGIC,
|
||||
WireError,
|
||||
WireReader,
|
||||
)
|
||||
|
||||
ContainerDecoder = Callable[[bytes], Any]
|
||||
|
||||
|
||||
def _split_decoder(decoder: ContainerDecoder | tuple[ContainerDecoder | None, ContainerDecoder | None] | None):
|
||||
if decoder is None:
|
||||
return None, None
|
||||
if isinstance(decoder, tuple):
|
||||
if len(decoder) != 2:
|
||||
raise ValueError("container decoder tuple must have two elements")
|
||||
return decoder
|
||||
return decoder, decoder
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilePatch:
|
||||
sync_header: SyncHeader
|
||||
sync_ops: list[SyncOp] | None = None
|
||||
bsdiff_header: BsdiffHeader | None = None
|
||||
bsdiff_controls: list[Control] | None = None
|
||||
|
||||
def is_rsync(self) -> bool:
|
||||
return self.sync_ops is not None
|
||||
|
||||
def is_bsdiff(self) -> bool:
|
||||
return self.bsdiff_header is not None
|
||||
|
||||
|
||||
class PatchReader:
|
||||
def __init__(self, stream, *, container_decoder: ContainerDecoder | tuple[ContainerDecoder | None, ContainerDecoder | None] | None = None):
|
||||
self._stream = stream
|
||||
self._raw_wire = WireReader(stream)
|
||||
self._wire: WireReader | None = None
|
||||
|
||||
self.header: PatchHeader | None = None
|
||||
self.target_container_raw: bytes | None = None
|
||||
self.source_container_raw: bytes | None = None
|
||||
self.target_container: Any | None = None
|
||||
self.source_container: Any | None = None
|
||||
|
||||
self._init_stream(container_decoder)
|
||||
|
||||
@classmethod
|
||||
def open(cls, path: str, *, container_decoder: ContainerDecoder | tuple[ContainerDecoder | None, ContainerDecoder | None] | None = None):
|
||||
return cls(open(path, "rb"), container_decoder=container_decoder)
|
||||
|
||||
def _init_stream(self, container_decoder):
|
||||
self._raw_wire.expect_magic(PATCH_MAGIC)
|
||||
self.header = self._raw_wire.read_message(PatchHeader)
|
||||
|
||||
decompressed = open_decompressed_reader(self._stream, self.header.compression)
|
||||
self._wire = WireReader(decompressed)
|
||||
|
||||
target_decoder, source_decoder = _split_decoder(container_decoder)
|
||||
self.target_container_raw, self.target_container = self._read_container(target_decoder)
|
||||
self.source_container_raw, self.source_container = self._read_container(source_decoder)
|
||||
|
||||
def _read_container(self, decoder: ContainerDecoder | None):
|
||||
assert self._wire is not None
|
||||
raw = self._wire.read_message_bytes()
|
||||
parsed = decoder(raw) if decoder else None
|
||||
return raw, parsed
|
||||
|
||||
def iter_file_entries(self) -> Iterator[FilePatch]:
|
||||
assert self._wire is not None
|
||||
file_index = 0
|
||||
while True:
|
||||
try:
|
||||
sync_header = self._wire.read_message(SyncHeader)
|
||||
except EOFError:
|
||||
return
|
||||
|
||||
if sync_header.file_index is None:
|
||||
sync_header.file_index = file_index
|
||||
else:
|
||||
file_index = int(sync_header.file_index)
|
||||
|
||||
header_type = sync_header.type
|
||||
if header_type is None:
|
||||
header_type = SyncHeaderType.RSYNC
|
||||
else:
|
||||
try:
|
||||
header_type = SyncHeaderType(int(header_type))
|
||||
except ValueError:
|
||||
raise WireError(f"unknown sync header type: {header_type}")
|
||||
|
||||
if header_type == SyncHeaderType.BSDIFF:
|
||||
bsdiff_header = self._wire.read_message(BsdiffHeader)
|
||||
controls: list[Control] = []
|
||||
while True:
|
||||
ctrl = self._wire.read_message(Control)
|
||||
controls.append(ctrl)
|
||||
if ctrl.eof:
|
||||
break
|
||||
|
||||
sentinel = self._wire.read_message(SyncOp)
|
||||
if sentinel.type != SyncOpType.HEY_YOU_DID_IT:
|
||||
raise WireError("expected BSDIFF sentinel SyncOp")
|
||||
|
||||
yield FilePatch(
|
||||
sync_header=sync_header,
|
||||
bsdiff_header=bsdiff_header,
|
||||
bsdiff_controls=controls,
|
||||
)
|
||||
file_index += 1
|
||||
continue
|
||||
|
||||
if header_type != SyncHeaderType.RSYNC:
|
||||
raise WireError(f"unsupported sync header type: {header_type}")
|
||||
|
||||
ops: list[SyncOp] = []
|
||||
while True:
|
||||
op = self._wire.read_message(SyncOp)
|
||||
if op.type == SyncOpType.HEY_YOU_DID_IT:
|
||||
break
|
||||
ops.append(op)
|
||||
|
||||
yield FilePatch(sync_header=sync_header, sync_ops=ops)
|
||||
file_index += 1
|
||||
|
||||
def close(self) -> None:
|
||||
if self._wire:
|
||||
self._wire.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
|
||||
|
||||
class SignatureReader:
|
||||
def __init__(self, stream, *, container_decoder: ContainerDecoder | None = None):
|
||||
self._stream = stream
|
||||
self._raw_wire = WireReader(stream)
|
||||
self._wire: WireReader | None = None
|
||||
|
||||
self.header: SignatureHeader | None = None
|
||||
self.container_raw: bytes | None = None
|
||||
self.container: Any | None = None
|
||||
|
||||
self._init_stream(container_decoder)
|
||||
|
||||
@classmethod
|
||||
def open(cls, path: str, *, container_decoder: ContainerDecoder | None = None):
|
||||
return cls(open(path, "rb"), container_decoder=container_decoder)
|
||||
|
||||
def _init_stream(self, container_decoder):
|
||||
self._raw_wire.expect_magic(SIGNATURE_MAGIC)
|
||||
self.header = self._raw_wire.read_message(SignatureHeader)
|
||||
|
||||
decompressed = open_decompressed_reader(self._stream, self.header.compression)
|
||||
self._wire = WireReader(decompressed)
|
||||
|
||||
self.container_raw = self._wire.read_message_bytes()
|
||||
self.container = container_decoder(self.container_raw) if container_decoder else None
|
||||
|
||||
def iter_block_hashes(self) -> Iterator[BlockHash]:
|
||||
assert self._wire is not None
|
||||
while True:
|
||||
try:
|
||||
yield self._wire.read_message(BlockHash)
|
||||
except EOFError:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
if self._wire:
|
||||
self._wire.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
|
||||
|
||||
class ManifestReader:
|
||||
def __init__(self, stream, *, container_decoder: ContainerDecoder | None = None):
|
||||
self._stream = stream
|
||||
self._raw_wire = WireReader(stream)
|
||||
self._wire: WireReader | None = None
|
||||
|
||||
self.header: ManifestHeader | None = None
|
||||
self.container_raw: bytes | None = None
|
||||
self.container: Any | None = None
|
||||
|
||||
self._init_stream(container_decoder)
|
||||
|
||||
@classmethod
|
||||
def open(cls, path: str, *, container_decoder: ContainerDecoder | None = None):
|
||||
return cls(open(path, "rb"), container_decoder=container_decoder)
|
||||
|
||||
def _init_stream(self, container_decoder):
|
||||
self._raw_wire.expect_magic(MANIFEST_MAGIC)
|
||||
self.header = self._raw_wire.read_message(ManifestHeader)
|
||||
|
||||
decompressed = open_decompressed_reader(self._stream, self.header.compression)
|
||||
self._wire = WireReader(decompressed)
|
||||
|
||||
self.container_raw = self._wire.read_message_bytes()
|
||||
self.container = container_decoder(self.container_raw) if container_decoder else None
|
||||
|
||||
def iter_block_hashes(self) -> Iterator[ManifestBlockHash]:
|
||||
assert self._wire is not None
|
||||
while True:
|
||||
try:
|
||||
yield self._wire.read_message(ManifestBlockHash)
|
||||
except EOFError:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
if self._wire:
|
||||
self._wire.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
|
||||
|
||||
class WoundsReader:
|
||||
def __init__(self, stream, *, container_decoder: ContainerDecoder | None = None):
|
||||
self._stream = stream
|
||||
self._wire = WireReader(stream)
|
||||
|
||||
self.header: WoundsHeader | None = None
|
||||
self.container_raw: bytes | None = None
|
||||
self.container: Any | None = None
|
||||
|
||||
self._init_stream(container_decoder)
|
||||
|
||||
@classmethod
|
||||
def open(cls, path: str, *, container_decoder: ContainerDecoder | None = None):
|
||||
return cls(open(path, "rb"), container_decoder=container_decoder)
|
||||
|
||||
def _init_stream(self, container_decoder):
|
||||
self._wire.expect_magic(WOUNDS_MAGIC)
|
||||
self.header = self._wire.read_message(WoundsHeader)
|
||||
self.container_raw = self._wire.read_message_bytes()
|
||||
self.container = container_decoder(self.container_raw) if container_decoder else None
|
||||
|
||||
def iter_wounds(self) -> Iterator[Wound]:
|
||||
while True:
|
||||
try:
|
||||
yield self._wire.read_message(Wound)
|
||||
except EOFError:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
self._wire.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
477
pwr/proto.py
Normal file
477
pwr/proto.py
Normal file
@@ -0,0 +1,477 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import Any, ClassVar, Dict, Tuple
|
||||
|
||||
|
||||
class ProtoError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _unsigned(value: int, bits: int) -> int:
|
||||
if value < 0:
|
||||
value = (1 << bits) + value
|
||||
return value
|
||||
|
||||
|
||||
def _signed(value: int, bits: int) -> int:
|
||||
sign_bit = 1 << (bits - 1)
|
||||
if value & sign_bit:
|
||||
return value - (1 << bits)
|
||||
return value
|
||||
|
||||
|
||||
def _encode_varint(value: int) -> bytes:
|
||||
if value < 0:
|
||||
raise ProtoError("varint cannot encode negative values")
|
||||
out = bytearray()
|
||||
while True:
|
||||
to_write = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
out.append(to_write | 0x80)
|
||||
else:
|
||||
out.append(to_write)
|
||||
break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _encode_key(field_number: int, wire_type: int) -> bytes:
|
||||
return _encode_varint((field_number << 3) | wire_type)
|
||||
|
||||
|
||||
class ProtoReader:
|
||||
def __init__(self, data: bytes):
|
||||
self._data = bytes(data)
|
||||
self._pos = 0
|
||||
|
||||
def eof(self) -> bool:
|
||||
return self._pos >= len(self._data)
|
||||
|
||||
def read_varint(self) -> int:
|
||||
shift = 0
|
||||
result = 0
|
||||
while True:
|
||||
if self._pos >= len(self._data):
|
||||
raise ProtoError("unexpected EOF while reading varint")
|
||||
byte = self._data[self._pos]
|
||||
self._pos += 1
|
||||
result |= (byte & 0x7F) << shift
|
||||
if not (byte & 0x80):
|
||||
return result
|
||||
shift += 7
|
||||
if shift > 64:
|
||||
raise ProtoError("varint too long")
|
||||
|
||||
def read_key(self) -> Tuple[int, int]:
|
||||
key = self.read_varint()
|
||||
return key >> 3, key & 0x07
|
||||
|
||||
def read_length_delimited(self) -> bytes:
|
||||
length = self.read_varint()
|
||||
end = self._pos + length
|
||||
if end > len(self._data):
|
||||
raise ProtoError("unexpected EOF while reading length-delimited field")
|
||||
data = self._data[self._pos:end]
|
||||
self._pos = end
|
||||
return data
|
||||
|
||||
def skip(self, wire_type: int) -> None:
|
||||
if wire_type == 0:
|
||||
self.read_varint()
|
||||
return
|
||||
if wire_type == 1:
|
||||
self._pos += 8
|
||||
return
|
||||
if wire_type == 2:
|
||||
length = self.read_varint()
|
||||
self._pos += length
|
||||
return
|
||||
if wire_type == 5:
|
||||
self._pos += 4
|
||||
return
|
||||
raise ProtoError(f"unsupported wire type: {wire_type}")
|
||||
|
||||
|
||||
class ProtoMessage:
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {}
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
out = bytearray()
|
||||
for field_number in sorted(self.FIELDS):
|
||||
name, field_type, extra = self.FIELDS[field_number]
|
||||
value = getattr(self, name)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
repeated = False
|
||||
if field_type.startswith("repeated_"):
|
||||
repeated = True
|
||||
field_type = field_type[len("repeated_") :]
|
||||
|
||||
values = value if repeated else [value]
|
||||
for item in values:
|
||||
if field_type == "message":
|
||||
if isinstance(item, (bytes, bytearray, memoryview)):
|
||||
raw = bytes(item)
|
||||
else:
|
||||
raw = item.to_bytes()
|
||||
out.extend(_encode_key(field_number, 2))
|
||||
out.extend(_encode_varint(len(raw)))
|
||||
out.extend(raw)
|
||||
continue
|
||||
|
||||
if field_type == "bytes":
|
||||
raw = bytes(item)
|
||||
out.extend(_encode_key(field_number, 2))
|
||||
out.extend(_encode_varint(len(raw)))
|
||||
out.extend(raw)
|
||||
continue
|
||||
|
||||
if field_type == "string":
|
||||
raw = str(item).encode("utf-8")
|
||||
out.extend(_encode_key(field_number, 2))
|
||||
out.extend(_encode_varint(len(raw)))
|
||||
out.extend(raw)
|
||||
continue
|
||||
|
||||
if field_type == "bool":
|
||||
out.extend(_encode_key(field_number, 0))
|
||||
out.extend(_encode_varint(1 if item else 0))
|
||||
continue
|
||||
|
||||
if field_type == "enum":
|
||||
if isinstance(item, IntEnum):
|
||||
item = int(item)
|
||||
out.extend(_encode_key(field_number, 0))
|
||||
out.extend(_encode_varint(_unsigned(int(item), 64)))
|
||||
continue
|
||||
|
||||
if field_type in ("int32", "int64"):
|
||||
bits = 32 if field_type == "int32" else 64
|
||||
out.extend(_encode_key(field_number, 0))
|
||||
out.extend(_encode_varint(_unsigned(int(item), bits)))
|
||||
continue
|
||||
|
||||
if field_type in ("uint32", "uint64"):
|
||||
out.extend(_encode_key(field_number, 0))
|
||||
out.extend(_encode_varint(int(item)))
|
||||
continue
|
||||
|
||||
raise ProtoError(f"unsupported field type: {field_type}")
|
||||
|
||||
return bytes(out)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
reader = ProtoReader(data)
|
||||
obj = cls()
|
||||
while not reader.eof():
|
||||
field_number, wire_type = reader.read_key()
|
||||
spec = cls.FIELDS.get(field_number)
|
||||
if spec is None:
|
||||
reader.skip(wire_type)
|
||||
continue
|
||||
name, field_type, extra = spec
|
||||
|
||||
repeated = False
|
||||
if field_type.startswith("repeated_"):
|
||||
repeated = True
|
||||
field_type = field_type[len("repeated_") :]
|
||||
|
||||
if field_type == "message":
|
||||
raw = reader.read_length_delimited()
|
||||
if extra is None:
|
||||
value = raw
|
||||
else:
|
||||
value = extra.from_bytes(raw)
|
||||
elif field_type == "bytes":
|
||||
value = reader.read_length_delimited()
|
||||
elif field_type == "string":
|
||||
value = reader.read_length_delimited().decode("utf-8")
|
||||
elif field_type == "bool":
|
||||
value = bool(reader.read_varint())
|
||||
elif field_type == "enum":
|
||||
raw_value = reader.read_varint()
|
||||
if extra is None:
|
||||
value = raw_value
|
||||
else:
|
||||
try:
|
||||
value = extra(raw_value)
|
||||
except ValueError:
|
||||
value = raw_value
|
||||
elif field_type == "int32":
|
||||
value = _signed(reader.read_varint(), 32)
|
||||
elif field_type == "int64":
|
||||
value = _signed(reader.read_varint(), 64)
|
||||
elif field_type == "uint32":
|
||||
value = reader.read_varint()
|
||||
elif field_type == "uint64":
|
||||
value = reader.read_varint()
|
||||
else:
|
||||
raise ProtoError(f"unsupported field type: {field_type}")
|
||||
|
||||
if repeated:
|
||||
current = getattr(obj, name, None)
|
||||
if current is None:
|
||||
current = []
|
||||
setattr(obj, name, current)
|
||||
current.append(value)
|
||||
else:
|
||||
setattr(obj, name, value)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
class CompressionAlgorithm(IntEnum):
|
||||
NONE = 0
|
||||
BROTLI = 1
|
||||
GZIP = 2
|
||||
ZSTD = 3
|
||||
|
||||
|
||||
class HashAlgorithm(IntEnum):
|
||||
SHAKE128_32 = 0
|
||||
CRC32C = 1
|
||||
|
||||
|
||||
class WoundKind(IntEnum):
|
||||
FILE = 0
|
||||
SYMLINK = 1
|
||||
DIR = 2
|
||||
CLOSED_FILE = 3
|
||||
|
||||
|
||||
class SyncHeaderType(IntEnum):
|
||||
RSYNC = 0
|
||||
BSDIFF = 1
|
||||
|
||||
|
||||
class SyncOpType(IntEnum):
|
||||
BLOCK_RANGE = 0
|
||||
DATA = 1
|
||||
HEY_YOU_DID_IT = 2049
|
||||
|
||||
|
||||
class OverlayOpType(IntEnum):
|
||||
SKIP = 0
|
||||
FRESH = 1
|
||||
HEY_YOU_DID_IT = 2040
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompressionSettings(ProtoMessage):
|
||||
algorithm: CompressionAlgorithm | int | None = None
|
||||
quality: int | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("algorithm", "enum", CompressionAlgorithm),
|
||||
2: ("quality", "int32", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatchHeader(ProtoMessage):
|
||||
compression: CompressionSettings | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("compression", "message", CompressionSettings),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncHeader(ProtoMessage):
|
||||
type: SyncHeaderType | int | None = None
|
||||
file_index: int | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("type", "enum", SyncHeaderType),
|
||||
16: ("file_index", "int64", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BsdiffHeader(ProtoMessage):
|
||||
target_index: int | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("target_index", "int64", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncOp(ProtoMessage):
|
||||
type: SyncOpType | int | None = None
|
||||
file_index: int | None = None
|
||||
block_index: int | None = None
|
||||
block_span: int | None = None
|
||||
data: bytes | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("type", "enum", SyncOpType),
|
||||
2: ("file_index", "int64", None),
|
||||
3: ("block_index", "int64", None),
|
||||
4: ("block_span", "int64", None),
|
||||
5: ("data", "bytes", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignatureHeader(ProtoMessage):
|
||||
compression: CompressionSettings | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("compression", "message", CompressionSettings),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockHash(ProtoMessage):
|
||||
weak_hash: int | None = None
|
||||
strong_hash: bytes | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("weak_hash", "uint32", None),
|
||||
2: ("strong_hash", "bytes", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestHeader(ProtoMessage):
|
||||
compression: CompressionSettings | None = None
|
||||
algorithm: HashAlgorithm | int | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("compression", "message", CompressionSettings),
|
||||
2: ("algorithm", "enum", HashAlgorithm),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestBlockHash(ProtoMessage):
|
||||
hash: bytes | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("hash", "bytes", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WoundsHeader(ProtoMessage):
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Wound(ProtoMessage):
|
||||
index: int | None = None
|
||||
start: int | None = None
|
||||
end: int | None = None
|
||||
kind: WoundKind | int | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("index", "int64", None),
|
||||
2: ("start", "int64", None),
|
||||
3: ("end", "int64", None),
|
||||
4: ("kind", "enum", WoundKind),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Control(ProtoMessage):
|
||||
add: bytes | None = None
|
||||
copy: bytes | None = None
|
||||
seek: int | None = None
|
||||
eof: bool | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("add", "bytes", None),
|
||||
2: ("copy", "bytes", None),
|
||||
3: ("seek", "int64", None),
|
||||
4: ("eof", "bool", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OverlayHeader(ProtoMessage):
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OverlayOp(ProtoMessage):
|
||||
type: OverlayOpType | int | None = None
|
||||
len: int | None = None
|
||||
data: bytes | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("type", "enum", OverlayOpType),
|
||||
2: ("len", "int64", None),
|
||||
3: ("data", "bytes", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sample(ProtoMessage):
|
||||
data: bytes | None = None
|
||||
number: int | None = None
|
||||
eof: bool | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("data", "bytes", None),
|
||||
2: ("number", "int64", None),
|
||||
3: ("eof", "bool", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TlcDir(ProtoMessage):
|
||||
path: str | None = None
|
||||
mode: int | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("path", "string", None),
|
||||
2: ("mode", "uint32", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TlcFile(ProtoMessage):
|
||||
path: str | None = None
|
||||
mode: int | None = None
|
||||
size: int | None = None
|
||||
offset: int | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("path", "string", None),
|
||||
2: ("mode", "uint32", None),
|
||||
3: ("size", "int64", None),
|
||||
4: ("offset", "int64", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TlcSymlink(ProtoMessage):
|
||||
path: str | None = None
|
||||
mode: int | None = None
|
||||
dest: str | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("path", "string", None),
|
||||
2: ("mode", "uint32", None),
|
||||
3: ("dest", "string", None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TlcContainer(ProtoMessage):
|
||||
files: list[TlcFile] = field(default_factory=list)
|
||||
dirs: list[TlcDir] = field(default_factory=list)
|
||||
symlinks: list[TlcSymlink] = field(default_factory=list)
|
||||
size: int | None = None
|
||||
|
||||
FIELDS: ClassVar[Dict[int, Tuple[str, str, Any]]] = {
|
||||
1: ("files", "repeated_message", TlcFile),
|
||||
2: ("dirs", "repeated_message", TlcDir),
|
||||
3: ("symlinks", "repeated_message", TlcSymlink),
|
||||
16: ("size", "int64", None),
|
||||
}
|
||||
140
pwr/wire.py
Normal file
140
pwr/wire.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import struct
|
||||
from typing import BinaryIO, Type, TypeVar
|
||||
|
||||
from .proto import ProtoError, ProtoMessage
|
||||
|
||||
PATCH_MAGIC = 0xFEF5F00
|
||||
SIGNATURE_MAGIC = PATCH_MAGIC + 1
|
||||
MANIFEST_MAGIC = PATCH_MAGIC + 2
|
||||
WOUNDS_MAGIC = PATCH_MAGIC + 3
|
||||
ZIP_INDEX_MAGIC = PATCH_MAGIC + 4
|
||||
|
||||
BLOCK_SIZE = 64 * 1024
|
||||
|
||||
|
||||
class WireError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _read_exact(stream: BinaryIO, size: int) -> bytes:
|
||||
if size <= 0:
|
||||
return b""
|
||||
try:
|
||||
data = stream.read(size)
|
||||
if data is not None:
|
||||
return data
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
readinto = getattr(stream, "readinto", None)
|
||||
if readinto is None:
|
||||
raise WireError("stream does not support read or readinto")
|
||||
|
||||
buf = bytearray(size)
|
||||
view = memoryview(buf)
|
||||
total = 0
|
||||
while total < size:
|
||||
n = readinto(view[total:])
|
||||
if n is None:
|
||||
n = 0
|
||||
if n == 0:
|
||||
break
|
||||
total += n
|
||||
return bytes(view[:total])
|
||||
|
||||
|
||||
def read_varint(stream: BinaryIO) -> int:
|
||||
shift = 0
|
||||
result = 0
|
||||
while True:
|
||||
b = _read_exact(stream, 1)
|
||||
if not b:
|
||||
raise EOFError("unexpected EOF while reading varint")
|
||||
byte = b[0]
|
||||
result |= (byte & 0x7F) << shift
|
||||
if not (byte & 0x80):
|
||||
return result
|
||||
shift += 7
|
||||
if shift > 64:
|
||||
raise WireError("varint too long")
|
||||
|
||||
|
||||
def write_varint(stream: BinaryIO, value: int) -> None:
|
||||
if value < 0:
|
||||
raise WireError("varint cannot encode negative values")
|
||||
while True:
|
||||
to_write = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
stream.write(bytes([to_write | 0x80]))
|
||||
else:
|
||||
stream.write(bytes([to_write]))
|
||||
break
|
||||
|
||||
|
||||
def read_magic(stream: BinaryIO) -> int:
|
||||
data = _read_exact(stream, 4)
|
||||
if len(data) != 4:
|
||||
raise EOFError("unexpected EOF while reading magic")
|
||||
return struct.unpack("<i", data)[0]
|
||||
|
||||
|
||||
def write_magic(stream: BinaryIO, magic: int) -> None:
|
||||
stream.write(struct.pack("<i", int(magic)))
|
||||
|
||||
|
||||
T = TypeVar("T", bound=ProtoMessage)
|
||||
|
||||
|
||||
class WireReader:
|
||||
def __init__(self, stream: BinaryIO):
|
||||
self._stream = stream
|
||||
|
||||
def read_magic(self) -> int:
|
||||
return read_magic(self._stream)
|
||||
|
||||
def expect_magic(self, magic: int) -> None:
|
||||
found = self.read_magic()
|
||||
if found != magic:
|
||||
raise WireError(f"wrong magic: expected {magic}, got {found}")
|
||||
|
||||
def read_message_bytes(self) -> bytes:
|
||||
length = read_varint(self._stream)
|
||||
data = _read_exact(self._stream, length)
|
||||
if len(data) != length:
|
||||
raise EOFError("unexpected EOF while reading message")
|
||||
return data
|
||||
|
||||
def read_message(self, msg_cls: Type[T]) -> T:
|
||||
data = self.read_message_bytes()
|
||||
try:
|
||||
return msg_cls.from_bytes(data)
|
||||
except ProtoError as exc:
|
||||
raise WireError(str(exc)) from exc
|
||||
|
||||
def close(self) -> None:
|
||||
if isinstance(self._stream, io.IOBase):
|
||||
self._stream.close()
|
||||
|
||||
|
||||
class WireWriter:
|
||||
def __init__(self, stream: BinaryIO):
|
||||
self._stream = stream
|
||||
|
||||
def write_magic(self, magic: int) -> None:
|
||||
write_magic(self._stream, magic)
|
||||
|
||||
def write_message(self, msg: ProtoMessage | bytes | bytearray | memoryview) -> None:
|
||||
if isinstance(msg, (bytes, bytearray, memoryview)):
|
||||
data = bytes(msg)
|
||||
else:
|
||||
data = msg.to_bytes()
|
||||
write_varint(self._stream, len(data))
|
||||
self._stream.write(data)
|
||||
|
||||
def close(self) -> None:
|
||||
if isinstance(self._stream, io.IOBase):
|
||||
self._stream.close()
|
||||
10
pyproject.toml
Normal file
10
pyproject.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[project]
|
||||
name = "py-pwr"
|
||||
version = "0.1.0"
|
||||
description = "Python client for wharf/pwr wire formats"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"brotli>=1.2.0",
|
||||
"zstandard>=0.25.0",
|
||||
]
|
||||
113
uv.lock
generated
Normal file
113
uv.lock
generated
Normal file
@@ -0,0 +1,113 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-pwr"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "brotli" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "brotli", specifier = ">=1.2.0" },
|
||||
{ name = "zstandard", specifier = ">=0.25.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user