Only use built-in zipfile module

This commit is contained in:
Timothy Farrell 2026-05-04 21:49:29 -05:00
parent b373313cf8
commit 0659873e12
6 changed files with 63 additions and 112 deletions

13
main.py
View File

@ -72,13 +72,8 @@ class ZipFileIndexer(FileIndexer):
self._password_cache: dict[str, bool] = {} self._password_cache: dict[str, bool] = {}
def _open_zip(self): def _open_zip(self):
"""Open the ZIP file, using pyzipper if available for AES support.""" """Open the ZIP file for reading."""
try: return zipfile.ZipFile(self.path, "r")
import pyzipper
return pyzipper.AESZipFile(self.path, "r")
except ImportError:
return zipfile.ZipFile(self.path, "r")
def _index(self) -> dict[str, str]: def _index(self) -> dict[str, str]:
"""Index all files in the zip file""" """Index all files in the zip file"""
@ -383,9 +378,7 @@ async def hash_page(
image_click_url = _build_url(file_hash) image_click_url = _build_url(file_hash)
# Create pause button to stop auto-refresh # Create pause button to stop auto-refresh
pause_button = ( pause_button = f'<a href="{file_hash}" class="play-btn" title="Pause">⏸</a>'
f'<a href="{file_hash}" class="play-btn" title="Pause">⏸</a>'
)
return _render_page( return _render_page(
navigation_data, navigation_data,

View File

@ -36,6 +36,5 @@ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"pytest>=9.0.3", "pytest>=9.0.3",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"pyzipper>=0.3.6",
"ruff>=0.15.5", "ruff>=0.15.5",
] ]

View File

@ -1,6 +1,7 @@
"""Shared fixtures for the test suite.""" """Shared fixtures for the test suite."""
import argparse import argparse
import shutil
import zipfile import zipfile
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path from pathlib import Path
@ -10,11 +11,6 @@ from httpx import ASGITransport, AsyncClient
import main import main
try:
import pyzipper
except ImportError:
pyzipper = None
def _reset_state() -> None: def _reset_state() -> None:
"""Reset all global state to defaults.""" """Reset all global state to defaults."""
@ -131,26 +127,20 @@ async def client_zip(initialized_zip: None) -> Generator[AsyncClient]:
@pytest.fixture @pytest.fixture
def sample_encrypted_zip(tmp_path: Path) -> tuple[Path, dict[str, str], str]: def sample_encrypted_zip(tmp_path: Path) -> tuple[Path, dict[str, str], str]:
"""Create a ZIP file with password-protected files. """Copy the pre-existing testfile.zip to a temp location.
Returns: Returns:
Tuple of (zip_path, inner_files dict, password). Tuple of (zip_path, inner_files dict, password).
inner_files maps logical names to internal paths. inner_files maps logical names to internal paths.
""" """
if pyzipper is None: src = Path(__file__).parent / "testfile.zip"
pytest.skip("pyzipper not installed") zip_path = tmp_path / "testfile.zip"
shutil.copy2(src, zip_path)
zip_path = tmp_path / "encrypted.zip" password = "password"
password = "secret123" inner_files: dict[str, str] = {
inner_files: dict[str, str] = {} "encrypted": "encrypted.txt",
"unencrypted": "unencrypted.txt",
with pyzipper.AESZipFile(zip_path, "w", encryption=pyzipper.WZ_AES) as zf: }
zf.setpassword(password.encode())
zf.writestr("protected.txt", "secret content")
inner_files["protected"] = "protected.txt"
zf.writestr("folder/secret.png", b"\x89PNG fake")
inner_files["secret_image"] = "folder/secret.png"
return zip_path, inner_files, password return zip_path, inner_files, password

View File

@ -18,17 +18,17 @@ class TestLazyEncryptionDetection:
"""Encrypted files are detected as needing a password.""" """Encrypted files are detected as needing a password."""
zip_path, inner_files, _ = sample_encrypted_zip zip_path, inner_files, _ = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test") indexer = main.ZipFileIndexer(str(zip_path), salt="test")
protected_hash = indexer._hash_path(inner_files["protected"]) encrypted_hash = indexer._hash_path(inner_files["encrypted"])
assert indexer.is_file_encrypted(protected_hash) is True assert indexer.is_file_encrypted(encrypted_hash) is True
def test_detects_unencrypted_file( def test_detects_unencrypted_file(
self, sample_zip: dict[str, Path], tmp_path: Path self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
) -> None: ) -> None:
"""Unencrypted files are detected as not needing a password.""" """Unencrypted files are detected as not needing a password."""
zip_path = tmp_path / "test_archive.zip" zip_path, inner_files, _ = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test-salt") indexer = main.ZipFileIndexer(str(zip_path), salt="test")
top_hash = indexer._hash_path("top.txt") unencrypted_hash = indexer._hash_path(inner_files["unencrypted"])
assert indexer.is_file_encrypted(top_hash) is False assert indexer.is_file_encrypted(unencrypted_hash) is False
def test_caches_detection_result( def test_caches_detection_result(
self, sample_encrypted_zip: tuple[Path, dict[str, str], str] self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
@ -36,14 +36,14 @@ class TestLazyEncryptionDetection:
"""Second call to is_file_encrypted uses the cache.""" """Second call to is_file_encrypted uses the cache."""
zip_path, inner_files, _ = sample_encrypted_zip zip_path, inner_files, _ = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test") indexer = main.ZipFileIndexer(str(zip_path), salt="test")
protected_hash = indexer._hash_path(inner_files["protected"]) encrypted_hash = indexer._hash_path(inner_files["encrypted"])
# First call populates the cache # First call populates the cache
assert indexer.is_file_encrypted(protected_hash) is True assert indexer.is_file_encrypted(encrypted_hash) is True
assert "protected.txt" in indexer._password_cache assert "encrypted.txt" in indexer._password_cache
# Second call returns cached value # Second call returns cached value
assert indexer.is_file_encrypted(protected_hash) is True assert indexer.is_file_encrypted(encrypted_hash) is True
def test_returns_false_for_unknown_hash( def test_returns_false_for_unknown_hash(
self, sample_encrypted_zip: tuple[Path, dict[str, str], str] self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
@ -59,11 +59,11 @@ class TestLazyEncryptionDetection:
"""Reading an encrypted file with the correct password succeeds.""" """Reading an encrypted file with the correct password succeeds."""
zip_path, inner_files, password = sample_encrypted_zip zip_path, inner_files, password = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test") indexer = main.ZipFileIndexer(str(zip_path), salt="test")
protected_hash = indexer._hash_path(inner_files["protected"]) encrypted_hash = indexer._hash_path(inner_files["encrypted"])
content = b"".join( content = b"".join(
indexer.get_file_by_hash(protected_hash, password=password.encode()) indexer.get_file_by_hash(encrypted_hash, password=password.encode())
) )
assert content == b"secret content" assert content == b"You have read this file\n"
def test_get_file_with_wrong_password_raises( def test_get_file_with_wrong_password_raises(
self, sample_encrypted_zip: tuple[Path, dict[str, str], str] self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
@ -71,19 +71,19 @@ class TestLazyEncryptionDetection:
"""Reading an encrypted file with the wrong password raises.""" """Reading an encrypted file with the wrong password raises."""
zip_path, inner_files, _ = sample_encrypted_zip zip_path, inner_files, _ = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test") indexer = main.ZipFileIndexer(str(zip_path), salt="test")
protected_hash = indexer._hash_path(inner_files["protected"]) encrypted_hash = indexer._hash_path(inner_files["encrypted"])
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
list(indexer.get_file_by_hash(protected_hash, password=b"wrong")) list(indexer.get_file_by_hash(encrypted_hash, password=b"wrong"))
def test_get_unencrypted_file_without_password( def test_get_unencrypted_file_without_password(
self, sample_zip: dict[str, Path], tmp_path: Path self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
) -> None: ) -> None:
"""Reading an unencrypted file works without a password.""" """Reading an unencrypted file works without a password."""
zip_path = tmp_path / "test_archive.zip" zip_path, inner_files, _ = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test-salt") indexer = main.ZipFileIndexer(str(zip_path), salt="test")
top_hash = indexer._hash_path("top.txt") unencrypted_hash = indexer._hash_path(inner_files["unencrypted"])
content = b"".join(indexer.get_file_by_hash(top_hash)) content = b"".join(indexer.get_file_by_hash(unencrypted_hash))
assert content == b"top level content" assert content == b"You have read this file\n"
class TestEncryptedZipEndpoints: class TestEncryptedZipEndpoints:
@ -99,47 +99,47 @@ class TestEncryptedZipEndpoints:
) -> None: ) -> None:
"""Encrypted file returns 401 without auth on /api/{hash}/data.""" """Encrypted file returns 401 without auth on /api/{hash}/data."""
for hash_val, filepath in main.file_mapping.items(): for hash_val, filepath in main.file_mapping.items():
if filepath == "protected.txt": if filepath == "encrypted.txt":
response = await client_encrypted_zip.get(f"/api/{hash_val}/data") response = await client_encrypted_zip.get(f"/api/{hash_val}/data")
assert response.status_code == 401 assert response.status_code == 401
break break
else: else:
pytest.fail("protected.txt not found in file_mapping") pytest.fail("encrypted.txt not found in file_mapping")
async def test_encrypted_file_requires_auth_on_hash_page( async def test_encrypted_file_requires_auth_on_hash_page(
self, client_encrypted_zip: AsyncClient self, client_encrypted_zip: AsyncClient
) -> None: ) -> None:
"""Encrypted file returns 401 without auth on /{hash}.""" """Encrypted file returns 401 without auth on /{hash}."""
for hash_val, filepath in main.file_mapping.items(): for hash_val, filepath in main.file_mapping.items():
if filepath == "protected.txt": if filepath == "encrypted.txt":
response = await client_encrypted_zip.get(f"/{hash_val}") response = await client_encrypted_zip.get(f"/{hash_val}")
assert response.status_code == 401 assert response.status_code == 401
break break
else: else:
pytest.fail("protected.txt not found in file_mapping") pytest.fail("encrypted.txt not found in file_mapping")
async def test_encrypted_file_succeeds_with_correct_auth( async def test_encrypted_file_succeeds_with_correct_auth(
self, client_encrypted_zip: AsyncClient self, client_encrypted_zip: AsyncClient
) -> None: ) -> None:
"""Encrypted file returns 200 with correct auth on /api/{hash}/data.""" """Encrypted file returns 200 with correct auth on /api/{hash}/data."""
for hash_val, filepath in main.file_mapping.items(): for hash_val, filepath in main.file_mapping.items():
if filepath == "protected.txt": if filepath == "encrypted.txt":
response = await client_encrypted_zip.get( response = await client_encrypted_zip.get(
f"/api/{hash_val}/data", f"/api/{hash_val}/data",
headers={"Authorization": self._basic_auth("secret123")}, headers={"Authorization": self._basic_auth("password")},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.content == b"secret content" assert response.content == b"You have read this file\n"
break break
else: else:
pytest.fail("protected.txt not found in file_mapping") pytest.fail("encrypted.txt not found in file_mapping")
async def test_encrypted_file_wrong_password_returns_401( async def test_encrypted_file_wrong_password_returns_401(
self, client_encrypted_zip: AsyncClient self, client_encrypted_zip: AsyncClient
) -> None: ) -> None:
"""Encrypted file returns 401 with wrong password.""" """Encrypted file returns 401 with wrong password."""
for hash_val, filepath in main.file_mapping.items(): for hash_val, filepath in main.file_mapping.items():
if filepath == "protected.txt": if filepath == "encrypted.txt":
response = await client_encrypted_zip.get( response = await client_encrypted_zip.get(
f"/api/{hash_val}/data", f"/api/{hash_val}/data",
headers={"Authorization": self._basic_auth("wrong")}, headers={"Authorization": self._basic_auth("wrong")},
@ -147,37 +147,50 @@ class TestEncryptedZipEndpoints:
assert response.status_code == 401 assert response.status_code == 401
break break
else: else:
pytest.fail("protected.txt not found in file_mapping") pytest.fail("encrypted.txt not found in file_mapping")
async def test_www_authenticate_header_present( async def test_www_authenticate_header_present(
self, client_encrypted_zip: AsyncClient self, client_encrypted_zip: AsyncClient
) -> None: ) -> None:
"""401 response includes WWW-Authenticate header.""" """401 response includes WWW-Authenticate header."""
for hash_val, filepath in main.file_mapping.items(): for hash_val, filepath in main.file_mapping.items():
if filepath == "protected.txt": if filepath == "encrypted.txt":
response = await client_encrypted_zip.get(f"/api/{hash_val}/data") response = await client_encrypted_zip.get(f"/api/{hash_val}/data")
assert response.status_code == 401 assert response.status_code == 401
assert "www-authenticate" in response.headers assert "www-authenticate" in response.headers
assert "Basic" in response.headers["www-authenticate"] assert "Basic" in response.headers["www-authenticate"]
break break
else: else:
pytest.fail("protected.txt not found in file_mapping") pytest.fail("encrypted.txt not found in file_mapping")
async def test_encrypted_hash_page_succeeds_with_auth( async def test_encrypted_hash_page_succeeds_with_auth(
self, client_encrypted_zip: AsyncClient self, client_encrypted_zip: AsyncClient
) -> None: ) -> None:
"""Encrypted file hash page returns 200 with correct auth.""" """Encrypted file hash page returns 200 with correct auth."""
for hash_val, filepath in main.file_mapping.items(): for hash_val, filepath in main.file_mapping.items():
if filepath == "protected.txt": if filepath == "encrypted.txt":
response = await client_encrypted_zip.get( response = await client_encrypted_zip.get(
f"/{hash_val}", f"/{hash_val}",
headers={"Authorization": self._basic_auth("secret123")}, headers={"Authorization": self._basic_auth("password")},
) )
assert response.status_code == 200 assert response.status_code == 200
assert "text/html" in response.headers["content-type"] assert "text/html" in response.headers["content-type"]
break break
else: else:
pytest.fail("protected.txt not found in file_mapping") pytest.fail("encrypted.txt not found in file_mapping")
async def test_unencrypted_file_no_auth_required(
self, client_encrypted_zip: AsyncClient
) -> None:
"""Unencrypted files in the same ZIP work without any auth."""
for hash_val, filepath in main.file_mapping.items():
if filepath == "unencrypted.txt":
response = await client_encrypted_zip.get(f"/api/{hash_val}/data")
assert response.status_code == 200
assert response.content == b"You have read this file\n"
break
else:
pytest.fail("unencrypted.txt not found in file_mapping")
async def test_unencrypted_zip_no_auth_required( async def test_unencrypted_zip_no_auth_required(
self, client_zip: AsyncClient self, client_zip: AsyncClient

BIN
tests/testfile.zip Normal file

Binary file not shown.

44
uv.lock generated
View File

@ -124,7 +124,6 @@ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pyzipper" },
{ name = "ruff" }, { name = "ruff" },
] ]
@ -142,7 +141,6 @@ dev = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=9.0.3" }, { name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pyzipper", specifier = ">=0.3.6" },
{ name = "ruff", specifier = ">=0.15.5" }, { name = "ruff", specifier = ">=0.15.5" },
] ]
@ -246,36 +244,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "pycryptodomex"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" },
{ url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" },
{ url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" },
{ url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" },
{ url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" },
{ url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" },
{ url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" },
{ url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" },
{ url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" },
{ url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" },
{ url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" },
{ url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" },
{ url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" },
{ url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" },
{ url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" },
{ url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" },
{ url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" },
{ url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
{ url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@ -405,18 +373,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
] ]
[[package]]
name = "pyzipper"
version = "0.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycryptodomex" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/97/2f03c67b40e531b30f0e1357476b4db989097a92cd30c6d2389cfa12db49/pyzipper-0.3.6.tar.gz", hash = "sha256:0adca90a00c36a93fbe49bfa8c5add452bfe4ef85a1b8e3638739dd1c7b26bfc", size = 31377, upload-time = "2022-07-31T09:58:34.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/b8/9d5d7cf4d96db8efa39f232fb152e87231fdaa5072229e6517f77a18d9c7/pyzipper-0.3.6-py2.py3-none-any.whl", hash = "sha256:6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87", size = 67652, upload-time = "2022-07-31T09:58:31.945Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.5" version = "0.15.5"