diff --git a/entrypoint.sh b/entrypoint.sh index 1ca43b6c..f3416792 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -261,7 +261,12 @@ test_write() { fi FILE_CONTENT=$(cat "$test_file" 2>/dev/null || echo "") - rm -f "$test_file" + # A folder can be writable but not deletable (e.g. a Synology share without + # "Delete subfolders and files"). That is not a boot failure - the app writes + # files in place on such shares - so don't let a failed cleanup print an + # alarming error or fail the probe. + run_as_target_user rm -f "$test_file" 2>/dev/null || \ + echo "Note: could not remove test file in $folder (folder is writable but not deletable)" [ "$FILE_CONTENT" = "0123456789_TEST" ] result=$? if [ $result -eq 0 ]; then diff --git a/shelfmark/download/fs.py b/shelfmark/download/fs.py index 02701ea8..831ac28d 100644 --- a/shelfmark/download/fs.py +++ b/shelfmark/download/fs.py @@ -4,6 +4,7 @@ These utilities handle file collisions atomically, avoiding TOCTOU race conditio when multiple workers may try to write to the same path simultaneously. """ +import contextlib import errno import os import shutil @@ -104,6 +105,57 @@ _PUBLISH_VERIFY_RETRY_SECONDS = 0.25 _TEMPFILE_PREFIX = ".shelfmark." _TEMPFILE_SUFFIX = ".tmp" +# Destinations that accept writes but reject unlink/rename, e.g. a Synology share +# with "Delete subfolders and files" unticked. Publishing a temp file into place +# removes a directory entry, so those paths must be written in place instead. +_DELETE_DENIED_DIRS: set[str] = set() + + +class _PublishDeniedError(Exception): + """A fully-written temp file could not be renamed onto its final path.""" + + +def _is_delete_denied_error(error: Exception) -> bool: + return isinstance(error, OSError) and error.errno in {errno.EACCES, errno.EPERM} + + +def mark_delete_denied(directory: Path) -> None: + """Record that `directory` rejects deletes so later writes skip the temp file.""" + key = str(directory) + if key in _DELETE_DENIED_DIRS: + return + _DELETE_DENIED_DIRS.add(key) + logger.warning( + "Destination %s rejects delete/rename; writing files in place instead of " + "publishing atomically. Grant delete permission to restore atomic writes.", + directory, + ) + + +def clear_delete_denied(directory: Path) -> None: + """Forget recorded denials for `directory` and anything beneath it. + + Subdirectories get marked independently (an `organize` layout publishes into + per-author folders), so clearing only the exact key would leave a fixed + destination writing in place until restart. + """ + if not _DELETE_DENIED_DIRS: + return + key = str(directory) + prefix = f"{key}{os.sep}" + _DELETE_DENIED_DIRS.difference_update( + {marked for marked in _DELETE_DENIED_DIRS if marked == key or marked.startswith(prefix)} + ) + + +def is_delete_denied(directory: Path) -> bool: + """True if `directory` or one of its ancestors is known to reject deletes.""" + if not _DELETE_DENIED_DIRS: + return False + if str(directory) in _DELETE_DENIED_DIRS: + return True + return any(str(parent) in _DELETE_DENIED_DIRS for parent in directory.parents) + def _verify_transfer_size( dest: Path, @@ -361,10 +413,40 @@ def _create_temp_path(dest_path: Path) -> Path: return Path(temp_path) +def _discard_path(path: Path) -> None: + """Best-effort unlink that tolerates destinations which reject deletes.""" + try: + run_blocking_io(path.unlink, missing_ok=True) + except OSError as exc: + logger.warning("Could not remove %s: %s", path, exc) + + +def _copy_into_claimed(source_path: Path, dest_path: Path, expected_size: int) -> None: + """Copy content straight into an already-claimed destination path. + + Used when the destination rejects rename/unlink: there is no temp file to + publish, so the final name is written in place. This is not atomic - a + watcher can observe a partial file - but it is the only way to deliver on + such a share. `copyfile` (not `copy2`) because metadata copying needs chmod, + which those shares also tend to refuse. + """ + try: + run_blocking_io(shutil.copyfile, str(source_path), str(dest_path)) + _verify_transfer_size(dest_path, expected_size, "copy") + except Exception: + with contextlib.suppress(OSError): + run_blocking_io(dest_path.unlink, missing_ok=True) + raise + + def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool: """Publish a temp file to its final path without overwriting existing files. Returns True on success, False if the destination already exists. + + Raises `_PublishDeniedError` when the rename is refused for lack of delete + permission. The claimed destination is left in place so the caller can write + into it directly instead. """ claimed = _claim_destination(dest_path) if not claimed: @@ -374,7 +456,19 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool: # Publish by renaming the fully-written temp file into place. This gives # watchers an IN_MOVED_TO-style event on the final path instead of relying # on hardlink support in the destination filesystem. - run_blocking_io(os.replace, str(temp_path), str(dest_path)) + try: + run_blocking_io(os.replace, str(temp_path), str(dest_path)) + except OSError as e: + if _is_delete_denied_error(e): + log_transfer_permission_context( + "publish_replace", + source=temp_path, + dest=dest_path, + error=e, + ) + mark_delete_denied(dest_path.parent) + raise _PublishDeniedError(str(e)) from e + raise # Best-effort nudge for watchers that only react to close-write on the # final filename rather than rename/move events. @@ -383,6 +477,8 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool: run_blocking_io(os.close, fd) except OSError: pass + except _PublishDeniedError: + raise except Exception as e: if _is_permission_error(e): log_transfer_permission_context( @@ -391,12 +487,23 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool: dest=dest_path, error=e, ) - run_blocking_io(dest_path.unlink, missing_ok=True) + _discard_path(dest_path) raise else: return True +def _move_via_copy(source_path: Path, dest_path: Path, max_attempts: int) -> Path: + """Deliver a move as copy + source unlink. + + For destinations that reject rename. The source lives in TMP_DIR (which we + own and can delete), so only the destination-side semantics change. + """ + final_path = atomic_copy(source_path, dest_path, max_attempts=max_attempts) + _discard_path(source_path) + return final_path + + def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path: """Move a file with collision detection. @@ -423,6 +530,11 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> ext = dest_path.suffix parent = dest_path.parent + # rename() removes a directory entry, so a destination that refuses deletes + # cannot be moved into. Deliver it as copy + source unlink instead. + if is_delete_denied(parent): + return _move_via_copy(source_path, dest_path, max_attempts) + for attempt in range(max_attempts): try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}" @@ -449,6 +561,13 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> run_blocking_io(try_path.unlink, missing_ok=True) continue except OSError as e: + if _is_delete_denied_error(e): + # Destination refuses the rename; fall back to copy + unlink source. + mark_delete_denied(parent) + if claimed: + _discard_path(try_path) + return _move_via_copy(source_path, dest_path, max_attempts) + # Cross-filesystem - copy to temp and publish atomically. if e.errno != errno.EXDEV: if claimed: @@ -497,7 +616,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try: _verify_published_file(try_path, expected_size, "move") except Exception: - run_blocking_io(try_path.unlink, missing_ok=True) + _discard_path(try_path) raise run_blocking_io(source_path.unlink) @@ -508,9 +627,18 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> if temp_path: run_blocking_io(temp_path.unlink, missing_ok=True) continue + except _PublishDeniedError: + # Destination is claimed but unrenameable; write into it directly. + _copy_into_claimed(source_path, try_path, expected_size) + if temp_path: + _discard_path(temp_path) + _discard_path(source_path) + if attempt > 0: + logger.info("File collision resolved: %s", try_path.name) + return try_path except Exception: if temp_path: - run_blocking_io(temp_path.unlink, missing_ok=True) + _discard_path(temp_path) raise else: return try_path @@ -629,6 +757,17 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}" if run_blocking_io(try_path.exists): continue + + # Known-undeletable destination: skip the temp file entirely, otherwise + # every transfer would strand a `.shelfmark.*.tmp` we cannot clean up. + if is_delete_denied(parent): + if not _claim_destination(try_path): + continue + _copy_into_claimed(source_path, try_path, expected_size) + if attempt > 0: + logger.info("File collision resolved: %s", try_path.name) + return try_path + temp_path: Path | None = None try: temp_path = _create_temp_path(try_path) @@ -680,14 +819,22 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> try: _verify_published_file(try_path, expected_size, "copy") except Exception: - run_blocking_io(try_path.unlink, missing_ok=True) + _discard_path(try_path) raise if attempt > 0: logger.info("File collision resolved: %s", try_path.name) + except _PublishDeniedError: + # The destination is claimed but unrenameable; write into it directly. + _copy_into_claimed(source_path, try_path, expected_size) + if temp_path: + _discard_path(temp_path) + if attempt > 0: + logger.info("File collision resolved: %s", try_path.name) + return try_path except Exception: if temp_path: - run_blocking_io(temp_path.unlink, missing_ok=True) + _discard_path(temp_path) raise else: return try_path diff --git a/shelfmark/download/postprocess/destination.py b/shelfmark/download/postprocess/destination.py index c44add99..4096120d 100644 --- a/shelfmark/download/postprocess/destination.py +++ b/shelfmark/download/postprocess/destination.py @@ -3,7 +3,6 @@ from __future__ import annotations import contextlib -import uuid from typing import TYPE_CHECKING from shelfmark.core.logger import setup_logger @@ -13,7 +12,11 @@ from shelfmark.core.utils import ( from shelfmark.core.utils import ( is_audiobook as check_audiobook, ) -from shelfmark.download.fs import run_blocking_io +from shelfmark.download.fs import ( + clear_delete_denied, + mark_delete_denied, + run_blocking_io, +) from shelfmark.download.permissions_debug import log_path_permission_context from shelfmark.release_sources import get_source @@ -25,6 +28,8 @@ if TYPE_CHECKING: logger = setup_logger("shelfmark.download.postprocess.pipeline") +_WRITE_PROBE_NAME = ".shelfmark_write_test.tmp" + def validate_destination( destination: Path, status_callback: Callable[[str, str | None], None] @@ -52,7 +57,9 @@ def validate_destination( status_callback("error", f"Cannot create destination: {destination} ({exc})") return False - test_path = destination / f".shelfmark_write_test_{uuid.uuid4().hex}.tmp" + # Stable name: on shares that refuse deletes the probe file cannot be cleaned + # up, so reusing one name bounds the leftovers at a single hidden file. + test_path = destination / _WRITE_PROBE_NAME try: test_content = ( @@ -60,7 +67,6 @@ def validate_destination( "It should've been automatically deleted. Feel free to delete it.\n" ) run_blocking_io(test_path.write_text, test_content) - run_blocking_io(test_path.unlink, missing_ok=True) except OSError as exc: logger.debug("Destination write probe path: %s", test_path) log_path_permission_context("destination_write_probe", destination) @@ -71,6 +77,23 @@ def validate_destination( run_blocking_io(destination.rmdir) return False + try: + run_blocking_io(test_path.unlink, missing_ok=True) + except OSError as exc: + # Writable but not deletable, e.g. a Synology share with "Delete + # subfolders and files" unticked. Not fatal: record it so transfers write + # files in place instead of publishing a temp file via rename. + mark_delete_denied(destination) + logger.warning( + "Destination %s is writable but refuses deletes (%s); leaving probe file %s " + "behind and writing files in place", + destination, + exc, + test_path.name, + ) + else: + clear_delete_denied(destination) + return True diff --git a/tests/core/test_permission_handling.py b/tests/core/test_permission_handling.py index 55cd8e14..68c775c3 100644 --- a/tests/core/test_permission_handling.py +++ b/tests/core/test_permission_handling.py @@ -2,16 +2,27 @@ import errno from pathlib import Path from unittest.mock import MagicMock, patch +import pytest + from shelfmark.core.models import DownloadTask, SearchMode +from shelfmark.download import fs from shelfmark.download.postprocess.pipeline import collect_directory_files, validate_destination +@pytest.fixture(autouse=True) +def _reset_delete_denied(): + fs._DELETE_DENIED_DIRS.clear() + yield + fs._DELETE_DENIED_DIRS.clear() + + def test_validate_destination_success_cleans_up_probe(tmp_path): destination = tmp_path / "dest" status_cb = MagicMock() assert validate_destination(destination, status_cb) is True - assert list(destination.glob(".shelfmark_write_test_*")) == [] + assert list(destination.glob(".shelfmark_write_test*")) == [] + assert fs.is_delete_denied(destination) is False def test_validate_destination_write_probe_permission_error(tmp_path): @@ -22,7 +33,7 @@ def test_validate_destination_write_probe_permission_error(tmp_path): real_write_text = Path.write_text def fake_write_text(self, data, *args, **kwargs): - if ".shelfmark_write_test_" in self.name: + if ".shelfmark_write_test" in self.name: raise PermissionError(errno.EACCES, "Permission denied", str(self)) return real_write_text(self, data, *args, **kwargs) @@ -34,6 +45,37 @@ def test_validate_destination_write_probe_permission_error(tmp_path): assert "Destination not writable" in status_cb.call_args[0][1] +def test_validate_destination_accepts_writable_but_undeletable_destination(tmp_path): + """Synology-style share: creating files is allowed, deleting them is not.""" + destination = tmp_path / "dest" + destination.mkdir() + status_cb = MagicMock() + + real_unlink = Path.unlink + + def fake_unlink(self, *args, **kwargs): + if ".shelfmark_write_test" in self.name: + raise PermissionError(errno.EACCES, "Permission denied", str(self)) + return real_unlink(self, *args, **kwargs) + + with patch("pathlib.Path.unlink", new=fake_unlink): + assert validate_destination(destination, status_cb) is True + + # Not fatal: no error is surfaced, and the destination is flagged so + # transfers write in place instead of publishing via rename. + assert not any(call[0][0] == "error" for call in status_cb.call_args_list) + assert fs.is_delete_denied(destination) is True + + +def test_validate_destination_clears_stale_delete_denial(tmp_path): + destination = tmp_path / "dest" + destination.mkdir() + fs.mark_delete_denied(destination) + + assert validate_destination(destination, MagicMock()) is True + assert fs.is_delete_denied(destination) is False + + def test_collect_directory_files_ignores_permission_errors(tmp_path): directory = tmp_path / "download" directory.mkdir() diff --git a/tests/download/test_fs_delete_denied.py b/tests/download/test_fs_delete_denied.py new file mode 100644 index 00000000..bc57a0f0 --- /dev/null +++ b/tests/download/test_fs_delete_denied.py @@ -0,0 +1,170 @@ +"""Transfers into destinations that accept writes but refuse deletes. + +Reproduces the Synology case from issue #1174: a share where "Delete subfolders +and files" is unticked. Creating files is allowed, but `os.replace()` cannot +publish a temp file into place because renaming removes a directory entry. +""" + +from __future__ import annotations + +import errno +import os +from pathlib import Path + +import pytest + +from shelfmark.download import fs +from shelfmark.download.fs import atomic_copy, atomic_move + +CONTENT = b"a book" * 1024 + + +@pytest.fixture(autouse=True) +def _reset_delete_denied(): + fs._DELETE_DENIED_DIRS.clear() + yield + fs._DELETE_DENIED_DIRS.clear() + + +@pytest.fixture +def source(tmp_path: Path) -> Path: + src = tmp_path / "tmp_dir" / "staged.epub" + src.parent.mkdir() + src.write_bytes(CONTENT) + return src + + +@pytest.fixture +def library(tmp_path: Path) -> Path: + lib = tmp_path / "library" + lib.mkdir() + return lib + + +def _leftover_temps(directory: Path) -> list[Path]: + return list(directory.glob(".shelfmark.*")) + + +def _deny_replace(monkeypatch: pytest.MonkeyPatch) -> None: + """Make os.replace fail the way a no-delete share does.""" + + def fake_replace(src, dst, *args, **kwargs): + raise PermissionError(errno.EACCES, "Permission denied", str(dst)) + + monkeypatch.setattr(fs.os, "replace", fake_replace) + + +def test_copy_writes_in_place_when_destination_is_known_undeletable( + source: Path, library: Path +) -> None: + fs.mark_delete_denied(library) + + final_path = atomic_copy(source, library / "book.epub") + + assert final_path == library / "book.epub" + assert final_path.read_bytes() == CONTENT + # The temp file is skipped entirely, so nothing is stranded in the library. + assert _leftover_temps(library) == [] + assert source.exists() + + +def test_copy_falls_back_when_rename_is_refused( + source: Path, library: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Cold cache: the denial is discovered at publish time and recovered from.""" + _deny_replace(monkeypatch) + + final_path = atomic_copy(source, library / "book.epub") + + assert final_path.read_bytes() == CONTENT + assert _leftover_temps(library) == [] + # The destination is remembered so later transfers skip the temp file. + assert fs.is_delete_denied(library) is True + + +def test_move_falls_back_to_copy_and_removes_source(source: Path, library: Path) -> None: + fs.mark_delete_denied(library) + + final_path = atomic_move(source, library / "book.epub") + + assert final_path.read_bytes() == CONTENT + assert not source.exists() + assert _leftover_temps(library) == [] + + +def test_move_falls_back_when_rename_is_refused( + source: Path, library: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + real_rename = fs.os.rename + + def fake_rename(src, dst, *args, **kwargs): + if str(dst).startswith(str(library)): + raise PermissionError(errno.EACCES, "Permission denied", str(dst)) + return real_rename(src, dst, *args, **kwargs) + + monkeypatch.setattr(fs.os, "rename", fake_rename) + _deny_replace(monkeypatch) + + final_path = atomic_move(source, library / "book.epub") + + assert final_path.read_bytes() == CONTENT + assert not source.exists() + assert fs.is_delete_denied(library) is True + + +def test_in_place_copy_still_resolves_collisions(source: Path, library: Path) -> None: + fs.mark_delete_denied(library) + (library / "book.epub").write_bytes(b"existing") + + final_path = atomic_copy(source, library / "book.epub") + + assert final_path == library / "book_1.epub" + assert final_path.read_bytes() == CONTENT + # The pre-existing file is never overwritten. + assert (library / "book.epub").read_bytes() == b"existing" + + +def test_denial_applies_to_subdirectories(source: Path, library: Path) -> None: + """`organize` mode writes into per-author subfolders under the library root.""" + fs.mark_delete_denied(library) + nested = library / "Frank Herbert" / "Dune" + nested.mkdir(parents=True) + + assert fs.is_delete_denied(nested) is True + + final_path = atomic_copy(source, nested / "book.epub") + + assert final_path.read_bytes() == CONTENT + assert _leftover_temps(nested) == [] + + +def test_clearing_a_denial_also_clears_subdirectories(library: Path) -> None: + nested = library / "Frank Herbert" + fs.mark_delete_denied(library) + fs.mark_delete_denied(nested) + + fs.clear_delete_denied(library) + + assert fs.is_delete_denied(library) is False + assert fs.is_delete_denied(nested) is False + + +def test_normal_destination_still_publishes_atomically( + source: Path, library: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Regression guard: unaffected shares keep the temp-file + rename path.""" + replaced: list[tuple[str, str]] = [] + real_replace = os.replace + + def spy_replace(src, dst, *args, **kwargs): + replaced.append((str(src), str(dst))) + return real_replace(src, dst, *args, **kwargs) + + monkeypatch.setattr(fs.os, "replace", spy_replace) + + final_path = atomic_copy(source, library / "book.epub") + + assert final_path.read_bytes() == CONTENT + assert len(replaced) == 1 + assert Path(replaced[0][0]).name.startswith(".shelfmark.") + assert fs.is_delete_denied(library) is False diff --git a/uv.lock b/uv.lock index 13f9c509..33ad600d 100644 --- a/uv.lock +++ b/uv.lock @@ -452,29 +452,49 @@ wheels = [ [[package]] name = "greenlet" -version = "3.4.0" +version = "3.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, - { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, - { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, - { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, - { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, - { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, - { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, - { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, - { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, - { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, - { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, - { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, - { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, - { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, ] [[package]]