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

View File

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

View File

@ -1,6 +1,7 @@
"""Shared fixtures for the test suite."""
import argparse
import shutil
import zipfile
from collections.abc import Generator
from pathlib import Path
@ -10,11 +11,6 @@ from httpx import ASGITransport, AsyncClient
import main
try:
import pyzipper
except ImportError:
pyzipper = None
def _reset_state() -> None:
"""Reset all global state to defaults."""
@ -131,26 +127,20 @@ async def client_zip(initialized_zip: None) -> Generator[AsyncClient]:
@pytest.fixture
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:
Tuple of (zip_path, inner_files dict, password).
inner_files maps logical names to internal paths.
"""
if pyzipper is None:
pytest.skip("pyzipper not installed")
zip_path = tmp_path / "encrypted.zip"
password = "secret123"
inner_files: dict[str, str] = {}
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"
src = Path(__file__).parent / "testfile.zip"
zip_path = tmp_path / "testfile.zip"
shutil.copy2(src, zip_path)
password = "password"
inner_files: dict[str, str] = {
"encrypted": "encrypted.txt",
"unencrypted": "unencrypted.txt",
}
return zip_path, inner_files, password

View File

@ -18,17 +18,17 @@ class TestLazyEncryptionDetection:
"""Encrypted files are detected as needing a password."""
zip_path, inner_files, _ = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test")
protected_hash = indexer._hash_path(inner_files["protected"])
assert indexer.is_file_encrypted(protected_hash) is True
encrypted_hash = indexer._hash_path(inner_files["encrypted"])
assert indexer.is_file_encrypted(encrypted_hash) is True
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:
"""Unencrypted files are detected as not needing a password."""
zip_path = tmp_path / "test_archive.zip"
indexer = main.ZipFileIndexer(str(zip_path), salt="test-salt")
top_hash = indexer._hash_path("top.txt")
assert indexer.is_file_encrypted(top_hash) is False
zip_path, inner_files, _ = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test")
unencrypted_hash = indexer._hash_path(inner_files["unencrypted"])
assert indexer.is_file_encrypted(unencrypted_hash) is False
def test_caches_detection_result(
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."""
zip_path, inner_files, _ = sample_encrypted_zip
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
assert indexer.is_file_encrypted(protected_hash) is True
assert "protected.txt" in indexer._password_cache
assert indexer.is_file_encrypted(encrypted_hash) is True
assert "encrypted.txt" in indexer._password_cache
# 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(
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."""
zip_path, inner_files, password = sample_encrypted_zip
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(
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(
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."""
zip_path, inner_files, _ = sample_encrypted_zip
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):
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(
self, sample_zip: dict[str, Path], tmp_path: Path
self, sample_encrypted_zip: tuple[Path, dict[str, str], str]
) -> None:
"""Reading an unencrypted file works without a password."""
zip_path = tmp_path / "test_archive.zip"
indexer = main.ZipFileIndexer(str(zip_path), salt="test-salt")
top_hash = indexer._hash_path("top.txt")
content = b"".join(indexer.get_file_by_hash(top_hash))
assert content == b"top level content"
zip_path, inner_files, _ = sample_encrypted_zip
indexer = main.ZipFileIndexer(str(zip_path), salt="test")
unencrypted_hash = indexer._hash_path(inner_files["unencrypted"])
content = b"".join(indexer.get_file_by_hash(unencrypted_hash))
assert content == b"You have read this file\n"
class TestEncryptedZipEndpoints:
@ -99,47 +99,47 @@ class TestEncryptedZipEndpoints:
) -> None:
"""Encrypted file returns 401 without auth on /api/{hash}/data."""
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")
assert response.status_code == 401
break
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(
self, client_encrypted_zip: AsyncClient
) -> None:
"""Encrypted file returns 401 without auth on /{hash}."""
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}")
assert response.status_code == 401
break
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(
self, client_encrypted_zip: AsyncClient
) -> None:
"""Encrypted file returns 200 with correct auth on /api/{hash}/data."""
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",
headers={"Authorization": self._basic_auth("secret123")},
headers={"Authorization": self._basic_auth("password")},
)
assert response.status_code == 200
assert response.content == b"secret content"
assert response.content == b"You have read this file\n"
break
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(
self, client_encrypted_zip: AsyncClient
) -> None:
"""Encrypted file returns 401 with wrong password."""
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",
headers={"Authorization": self._basic_auth("wrong")},
@ -147,37 +147,50 @@ class TestEncryptedZipEndpoints:
assert response.status_code == 401
break
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(
self, client_encrypted_zip: AsyncClient
) -> None:
"""401 response includes WWW-Authenticate header."""
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")
assert response.status_code == 401
assert "www-authenticate" in response.headers
assert "Basic" in response.headers["www-authenticate"]
break
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(
self, client_encrypted_zip: AsyncClient
) -> None:
"""Encrypted file hash page returns 200 with correct auth."""
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}",
headers={"Authorization": self._basic_auth("secret123")},
headers={"Authorization": self._basic_auth("password")},
)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
break
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(
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 = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pyzipper" },
{ name = "ruff" },
]
@ -142,7 +141,6 @@ dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pyzipper", specifier = ">=0.3.6" },
{ 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" },
]
[[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]]
name = "pydantic"
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" },
]
[[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]]
name = "ruff"
version = "0.15.5"