diff --git a/.github/workflows/fuzz-smoke.yml b/.github/workflows/fuzz-smoke.yml index 2de85fc..0e53896 100644 --- a/.github/workflows/fuzz-smoke.yml +++ b/.github/workflows/fuzz-smoke.yml @@ -31,11 +31,8 @@ jobs: - name: Install Rust nightly run: rustup toolchain install nightly - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main - - name: Install cargo-fuzz - run: cargo binstall --no-confirm cargo-fuzz + run: cargo install --locked cargo-fuzz - name: Run fuzzing smoke tests id: fuzz diff --git a/.github/workflows/security-deep.yml b/.github/workflows/security-deep.yml index 5ca3be9..044369b 100644 --- a/.github/workflows/security-deep.yml +++ b/.github/workflows/security-deep.yml @@ -46,11 +46,8 @@ jobs: rustup toolchain install nightly rustup default nightly - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main - - name: Install cargo-fuzz - run: cargo binstall --no-confirm cargo-fuzz + run: cargo install --locked cargo-fuzz - name: Fuzz byte_storage_compress (1 hour) run: | diff --git a/.github/workflows/security-fast.yml b/.github/workflows/security-fast.yml index 2aa4d0a..791ad53 100644 --- a/.github/workflows/security-fast.yml +++ b/.github/workflows/security-fast.yml @@ -66,11 +66,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main - - name: Install cargo-machete - run: cargo binstall --no-confirm cargo-machete + run: cargo install --locked cargo-machete - name: Check for unused dependencies run: | diff --git a/.github/workflows/security-medium.yml b/.github/workflows/security-medium.yml index 7687f88..a1e8ae9 100644 --- a/.github/workflows/security-medium.yml +++ b/.github/workflows/security-medium.yml @@ -25,11 +25,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main - - name: Install cargo-geiger - run: cargo binstall --no-confirm cargo-geiger + run: cargo install --locked cargo-geiger - name: Run unsafe code analysis run: | diff --git a/pyproject.toml b/pyproject.toml index b8abd0d..c4173d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,6 +227,7 @@ dev = [ "pandas>=1.3.0", "pyarrow>=21.0.0", "pytest-xdist>=3.8.0", + "time-machine>=2.19.0", ] # Linux CI only - Atheris requires libFuzzer (not available on macOS without building LLVM) fuzz = [ diff --git a/tests/competitive/test_head_to_head.py b/tests/competitive/test_head_to_head.py index 8855f76..891049a 100644 --- a/tests/competitive/test_head_to_head.py +++ b/tests/competitive/test_head_to_head.py @@ -33,11 +33,12 @@ import math import time import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from functools import lru_cache from typing import Any import pytest +import time_machine from cachetools import TTLCache, cached from cachekit import cache @@ -560,25 +561,27 @@ def test_cachekit_has_ttl(self): """cachekit supports TTL with decorator parameter.""" call_count = 0 - @cache(backend=None, ttl=2) - def fn(x): - nonlocal call_count - call_count += 1 - return x * 2 + with time_machine.travel(0, tick=False) as traveller: - fn(1) - first_count = call_count + @cache(backend=None, ttl=2) + def fn(x): + nonlocal call_count + call_count += 1 + return x * 2 - fn(1) - # May or may not cache depending on L1 implementation details - second_count = call_count + fn(1) + first_count = call_count - time.sleep(2.5) # Wait for TTL - fn(1) - # After TTL expiry, function MUST re-execute - assert call_count > second_count, "Function should re-execute after TTL expires" + fn(1) + # May or may not cache depending on L1 implementation details + second_count = call_count - fn.cache_clear() + traveller.shift(timedelta(seconds=3)) # Advance clock past TTL + fn(1) + # After TTL expiry, function MUST re-execute + assert call_count > second_count, "Function should re-execute after TTL expires" + + fn.cache_clear() class TestCacheManagement: diff --git a/tests/competitive/test_lru_cache_comparison.py b/tests/competitive/test_lru_cache_comparison.py index 9402dc8..72fd4db 100644 --- a/tests/competitive/test_lru_cache_comparison.py +++ b/tests/competitive/test_lru_cache_comparison.py @@ -107,7 +107,8 @@ def get_data(x: int) -> str: assert result == "value_1" # Wait for TTL to expire - time.sleep(1.2) + # Server-side TTL — must sleep (time-machine can't mock Redis/Memcached clock) + time.sleep(3) # Data should be expired (in production with Redis) # Note: Exact behavior depends on Redis persistence diff --git a/tests/critical/test_basic_cache_works.py b/tests/critical/test_basic_cache_works.py index 3b2602f..eeeb888 100644 --- a/tests/critical/test_basic_cache_works.py +++ b/tests/critical/test_basic_cache_works.py @@ -174,7 +174,8 @@ def time_sensitive_data(): assert call_count == 1 # Wait for TTL to expire - time.sleep(1.5) + # Server-side TTL — must sleep (time-machine can't mock Redis/Memcached clock) + time.sleep(3) # Third call should execute function again (cache expired) result3 = time_sensitive_data() diff --git a/tests/critical/test_cache_reliability.py b/tests/critical/test_cache_reliability.py index f82014c..9791922 100644 --- a/tests/critical/test_cache_reliability.py +++ b/tests/critical/test_cache_reliability.py @@ -45,7 +45,8 @@ def time_sensitive_function(key): assert call_count == 1 # Wait for TTL to expire - time.sleep(1.2) + # Server-side TTL — must sleep (time-machine can't mock Redis/Memcached clock) + time.sleep(3) # Third call should get fresh data after expiration result3 = time_sensitive_function("data") diff --git a/tests/critical/test_circuit_breaker_state_machine.py b/tests/critical/test_circuit_breaker_state_machine.py index 3df97b4..7fc65ed 100644 --- a/tests/critical/test_circuit_breaker_state_machine.py +++ b/tests/critical/test_circuit_breaker_state_machine.py @@ -9,10 +9,11 @@ the application from overwhelming a struggling Redis instance. """ -import time +from datetime import timedelta import pytest import redis +import time_machine from cachekit.reliability.circuit_breaker import ( CircuitBreaker, @@ -56,21 +57,22 @@ def test_open_to_half_open_after_timeout(self): ) breaker = CircuitBreaker(config, namespace="test") - # Force circuit to OPEN state - for _ in range(3): - breaker.record_failure() - assert breaker.state == CircuitState.OPEN + with time_machine.travel(0, tick=False) as traveller: + # Force circuit to OPEN state + for _ in range(3): + breaker.record_failure() + assert breaker.state == CircuitState.OPEN - # Should reject immediately (before timeout) - assert not breaker.should_attempt_call(), "Should reject before timeout" - assert breaker.state == CircuitState.OPEN + # Should reject immediately (before timeout) + assert not breaker.should_attempt_call(), "Should reject before timeout" + assert breaker.state == CircuitState.OPEN - # Wait for timeout to expire - time.sleep(0.15) + # Advance past timeout + traveller.shift(timedelta(seconds=0.2)) - # Next request should transition to HALF_OPEN - assert breaker.should_attempt_call(), "Should allow test request after timeout" - assert breaker.state == CircuitState.HALF_OPEN, "Should transition to HALF_OPEN" + # Next request should transition to HALF_OPEN + assert breaker.should_attempt_call(), "Should allow test request after timeout" + assert breaker.state == CircuitState.HALF_OPEN, "Should transition to HALF_OPEN" def test_half_open_to_closed_after_success_threshold(self): """CRITICAL: Circuit closes after success_threshold successes in HALF_OPEN.""" @@ -82,26 +84,27 @@ def test_half_open_to_closed_after_success_threshold(self): ) breaker = CircuitBreaker(config, namespace="test") - # Force to OPEN then HALF_OPEN - for _ in range(3): - breaker.record_failure() - assert breaker.state == CircuitState.OPEN + with time_machine.travel(0, tick=False) as traveller: + # Force to OPEN then HALF_OPEN + for _ in range(3): + breaker.record_failure() + assert breaker.state == CircuitState.OPEN - time.sleep(0.15) - breaker.should_attempt_call() # Transition to HALF_OPEN - assert breaker.state == CircuitState.HALF_OPEN + traveller.shift(timedelta(seconds=0.2)) + breaker.should_attempt_call() # Transition to HALF_OPEN + assert breaker.state == CircuitState.HALF_OPEN - # Record successes up to threshold - 1 - for i in range(2): - breaker.record_success() - assert breaker.state == CircuitState.HALF_OPEN, f"Should stay HALF_OPEN at {i + 1} successes" - assert breaker.success_count == i + 1 + # Record successes up to threshold - 1 + for i in range(2): + breaker.record_success() + assert breaker.state == CircuitState.HALF_OPEN, f"Should stay HALF_OPEN at {i + 1} successes" + assert breaker.success_count == i + 1 - # One more success should close the circuit - breaker.record_success() - assert breaker.state == CircuitState.CLOSED, "Circuit should CLOSE after success threshold" - assert breaker.success_count == 0, "Success count should reset" - assert breaker.failure_count == 0, "Failure count should reset" + # One more success should close the circuit + breaker.record_success() + assert breaker.state == CircuitState.CLOSED, "Circuit should CLOSE after success threshold" + assert breaker.success_count == 0, "Success count should reset" + assert breaker.failure_count == 0, "Failure count should reset" def test_half_open_to_open_on_failure(self): """CRITICAL: Any failure in HALF_OPEN immediately returns to OPEN.""" @@ -112,22 +115,23 @@ def test_half_open_to_open_on_failure(self): ) breaker = CircuitBreaker(config, namespace="test") - # Force to OPEN then HALF_OPEN - for _ in range(3): - breaker.record_failure() - time.sleep(0.15) - breaker.should_attempt_call() # Transition to HALF_OPEN - assert breaker.state == CircuitState.HALF_OPEN + with time_machine.travel(0, tick=False) as traveller: + # Force to OPEN then HALF_OPEN + for _ in range(3): + breaker.record_failure() + traveller.shift(timedelta(seconds=0.2)) + breaker.should_attempt_call() # Transition to HALF_OPEN + assert breaker.state == CircuitState.HALF_OPEN - # Record some successes - breaker.record_success() - assert breaker.state == CircuitState.HALF_OPEN - assert breaker.success_count == 1 + # Record some successes + breaker.record_success() + assert breaker.state == CircuitState.HALF_OPEN + assert breaker.success_count == 1 - # Single failure should reopen circuit - breaker.record_failure() - assert breaker.state == CircuitState.OPEN, "Should return to OPEN on any HALF_OPEN failure" - assert breaker.success_count == 0, "Success count should reset" + # Single failure should reopen circuit + breaker.record_failure() + assert breaker.state == CircuitState.OPEN, "Should return to OPEN on any HALF_OPEN failure" + assert breaker.success_count == 0, "Success count should reset" def test_closed_state_with_intermittent_failures(self): """CRITICAL: Circuit stays CLOSED with intermittent failures below threshold.""" @@ -156,22 +160,23 @@ def test_half_open_request_limiting(self): ) breaker = CircuitBreaker(config, namespace="test") - # Force to OPEN then HALF_OPEN - for _ in range(3): - breaker.record_failure() - time.sleep(0.15) + with time_machine.travel(0, tick=False) as traveller: + # Force to OPEN then HALF_OPEN + for _ in range(3): + breaker.record_failure() + traveller.shift(timedelta(seconds=0.2)) - # First request should be allowed - assert breaker.should_attempt_call(), "First HALF_OPEN request should be allowed" - assert breaker.state == CircuitState.HALF_OPEN + # First request should be allowed + assert breaker.should_attempt_call(), "First HALF_OPEN request should be allowed" + assert breaker.state == CircuitState.HALF_OPEN - # Second request should be rejected (limit reached) - assert not breaker.should_attempt_call(), "Second request should be rejected (limit=1)" + # Second request should be rejected (limit reached) + assert not breaker.should_attempt_call(), "Second request should be rejected (limit=1)" - # After completing the first request successfully, circuit should close - breaker.record_success() - # Note: With success_threshold=3 (default), we need 3 successes to close - # So we stay in HALF_OPEN, but permits are reset + # After completing the first request successfully, circuit should close + breaker.record_success() + # Note: With success_threshold=3 (default), we need 3 successes to close + # So we stay in HALF_OPEN, but permits are reset def test_excluded_exceptions_dont_count_as_failures(self): """CRITICAL: Excluded error types don't trigger circuit breaker via call() method.""" @@ -317,48 +322,49 @@ def test_concurrent_state_transitions_thread_safe(self): ) breaker = CircuitBreaker(config, namespace="test") - failure_count = {"value": 0} - lock = threading.Lock() - - def record_failures(): - """Record failures concurrently.""" - for _ in range(5): - breaker.record_failure() - with lock: - failure_count["value"] += 1 - - # Launch multiple threads recording failures - threads = [threading.Thread(target=record_failures) for _ in range(3)] - for t in threads: - t.start() - for t in threads: - t.join() - - # Total failures: 3 threads * 5 failures = 15 failures - assert failure_count["value"] == 15 - # Circuit should be OPEN (threshold is 10) - assert breaker.state == CircuitState.OPEN - - # Wait for timeout and test concurrent HALF_OPEN transitions - time.sleep(0.15) - - allowed_count = {"value": 0} - - def attempt_half_open(): - """Attempt requests during HALF_OPEN state.""" - if breaker.should_attempt_call(): - with lock: - allowed_count["value"] += 1 - - # Only half_open_requests (1 by default) should be allowed - threads = [threading.Thread(target=attempt_half_open) for _ in range(10)] - for t in threads: - t.start() - for t in threads: - t.join() - - # Due to thread safety, only the configured number should be allowed - assert allowed_count["value"] == 1, "Only half_open_requests should be allowed concurrently" + with time_machine.travel(0, tick=False) as traveller: + failure_count = {"value": 0} + lock = threading.Lock() + + def record_failures(): + """Record failures concurrently.""" + for _ in range(5): + breaker.record_failure() + with lock: + failure_count["value"] += 1 + + # Launch multiple threads recording failures + threads = [threading.Thread(target=record_failures) for _ in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Total failures: 3 threads * 5 failures = 15 failures + assert failure_count["value"] == 15 + # Circuit should be OPEN (threshold is 10) + assert breaker.state == CircuitState.OPEN + + # Wait for timeout and test concurrent HALF_OPEN transitions + traveller.shift(timedelta(seconds=0.2)) + + allowed_count = {"value": 0} + + def attempt_half_open(): + """Attempt requests during HALF_OPEN state.""" + if breaker.should_attempt_call(): + with lock: + allowed_count["value"] += 1 + + # Only half_open_requests (1 by default) should be allowed + threads = [threading.Thread(target=attempt_half_open) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Due to thread safety, only the configured number should be allowed + assert allowed_count["value"] == 1, "Only half_open_requests should be allowed concurrently" def test_circuit_breaker_async_call_success(self): """CRITICAL: call_async method works with successful async functions.""" diff --git a/tests/critical/test_encryption_integration.py b/tests/critical/test_encryption_integration.py index d67869e..c9bfcde 100644 --- a/tests/critical/test_encryption_integration.py +++ b/tests/critical/test_encryption_integration.py @@ -377,7 +377,8 @@ def get_data(data_id: int): assert call_count == 1 # Wait for TTL expiration - time.sleep(1.5) + # Server-side TTL — must sleep (time-machine can't mock Redis/Memcached clock) + time.sleep(3) # After expiration - should execute again result3 = get_data(1) diff --git a/tests/critical/test_encryption_wrapper.py b/tests/critical/test_encryption_wrapper.py index 68265c7..c665e8f 100644 --- a/tests/critical/test_encryption_wrapper.py +++ b/tests/critical/test_encryption_wrapper.py @@ -622,7 +622,8 @@ def get_short_ttl_data(data_id): assert call_count == 1 # Wait for TTL expiration - time.sleep(1.5) + # Server-side TTL — must sleep (time-machine can't mock Redis/Memcached clock) + time.sleep(3) # After TTL - cache miss result3 = get_short_ttl_data(1) diff --git a/tests/critical/test_file_backend_critical.py b/tests/critical/test_file_backend_critical.py index 9d78915..d1cfa25 100644 --- a/tests/critical/test_file_backend_critical.py +++ b/tests/critical/test_file_backend_critical.py @@ -10,9 +10,10 @@ Marked with @pytest.mark.critical for fast CI runs. """ -import time +from datetime import timedelta import pytest +import time_machine from cachekit.backends.file.backend import FileBackend from cachekit.backends.file.config import FileBackendConfig @@ -50,24 +51,25 @@ def test_get_set_delete_roundtrip(backend): @pytest.mark.critical def test_ttl_enforced(backend): """TTL causes values to expire.""" - # Set with no TTL (permanent) - backend.set("permanent", b"stays") - # Set with short TTL - backend.set("temporary", b"goes_away", ttl=3) - - # Both exist immediately - assert backend.get("permanent") == b"stays" - assert backend.get("temporary") == b"goes_away" - - # Wait for temporary to expire - time.sleep(3.5) - - # Permanent still exists, temporary is gone - assert backend.get("permanent") == b"stays" - # Skip reading expired key directly due to file handle bug in FileBackend - # Instead verify by setting a new key (proves cleanup didn't affect backend) - backend.set("new_key", b"new_value") - assert backend.get("new_key") == b"new_value" + with time_machine.travel(0, tick=False) as traveller: + # Set with no TTL (permanent) + backend.set("permanent", b"stays") + # Set with short TTL + backend.set("temporary", b"goes_away", ttl=3) + + # Both exist immediately + assert backend.get("permanent") == b"stays" + assert backend.get("temporary") == b"goes_away" + + # Advance clock past TTL + traveller.shift(timedelta(seconds=5)) + + # Permanent still exists, temporary is gone + assert backend.get("permanent") == b"stays" + # Skip reading expired key directly due to file handle bug in FileBackend + # Instead verify by setting a new key (proves cleanup didn't affect backend) + backend.set("new_key", b"new_value") + assert backend.get("new_key") == b"new_value" @pytest.mark.critical diff --git a/tests/integration/test_memcached_integration.py b/tests/integration/test_memcached_integration.py index ea6e6cf..3b922c9 100644 --- a/tests/integration/test_memcached_integration.py +++ b/tests/integration/test_memcached_integration.py @@ -164,7 +164,8 @@ def test_key_expires_after_ttl(self, backend: MemcachedBackend) -> None: """Key should disappear after TTL seconds.""" backend.set("ephemeral", b"gone_soon", ttl=1) assert backend.get("ephemeral") == b"gone_soon" - time.sleep(1.5) + # Server-side TTL — must sleep (time-machine can't mock Redis/Memcached clock) + time.sleep(3) assert backend.get("ephemeral") is None def test_key_alive_before_ttl(self, backend: MemcachedBackend) -> None: diff --git a/tests/integration/test_redis_integration.py b/tests/integration/test_redis_integration.py index c15b63b..2f36fd8 100644 --- a/tests/integration/test_redis_integration.py +++ b/tests/integration/test_redis_integration.py @@ -89,7 +89,8 @@ def short_lived(): assert call_count == 1 # Wait for expiration - time.sleep(1.5) + # Server-side TTL — must sleep (time-machine can't mock Redis/Memcached clock) + time.sleep(4) # Now should execute again result3 = short_lived() diff --git a/tests/integration/test_tdd_cache_implementation.py b/tests/integration/test_tdd_cache_implementation.py index 2568988..2b4a762 100644 --- a/tests/integration/test_tdd_cache_implementation.py +++ b/tests/integration/test_tdd_cache_implementation.py @@ -117,7 +117,8 @@ def time_sensitive_func(): assert call_count == 1 # Wait for expiration - time.sleep(1.5) + # Server-side TTL — must sleep (time-machine can't mock Redis/Memcached clock) + time.sleep(3) # Third call - cache expired, function executes result3 = time_sensitive_func() diff --git a/tests/unit/backends/test_file_backend.py b/tests/unit/backends/test_file_backend.py index 2c88c8d..98ccc67 100644 --- a/tests/unit/backends/test_file_backend.py +++ b/tests/unit/backends/test_file_backend.py @@ -17,10 +17,12 @@ import os import struct import time +from datetime import timedelta from pathlib import Path from typing import Any import pytest +import time_machine from cachekit.backends.base import BaseBackend from cachekit.backends.file.backend import ( @@ -809,19 +811,16 @@ def test_get_expired_ttl_deletes_file(self, backend: FileBackend, config: FileBa """Test get deletes expired files.""" key = "expired_key" value = b"expired_value" - now = time.time() - # Set with 1 second TTL at real time - backend.set(key, value, ttl=1) + with time_machine.travel(0, tick=False) as traveller: + # Set with 1 second TTL at frozen time + backend.set(key, value, ttl=1) - # Verify it exists - assert backend.get(key) == value - - # Advance time past TTL instead of sleeping (no CI flake) - import unittest.mock + # Verify it exists + assert backend.get(key) == value - with unittest.mock.patch("cachekit.backends.file.backend.time") as mock_time: - mock_time.time.return_value = now + 10 # 10s in the future + # Advance clock 10s past TTL + traveller.shift(timedelta(seconds=10)) result = backend.get(key) assert result is None @@ -937,18 +936,15 @@ def test_exists_expired_ttl_deletes_file(self, backend: FileBackend, config: Fil """Test exists returns False and deletes expired file.""" key = "exists_expired" value = b"value" - now = time.time() - backend.set(key, value, ttl=1) - - # Verify it exists - assert backend.exists(key) is True + with time_machine.travel(0, tick=False) as traveller: + backend.set(key, value, ttl=1) - # Advance time past TTL instead of sleeping (no CI flake) - import unittest.mock + # Verify it exists + assert backend.exists(key) is True - with unittest.mock.patch("cachekit.backends.file.backend.time") as mock_time: - mock_time.time.return_value = now + 10 + # Advance clock 10s past TTL + traveller.shift(timedelta(seconds=10)) result = backend.exists(key) assert result is False diff --git a/tests/unit/test_circuit_breaker.py b/tests/unit/test_circuit_breaker.py index 32f1e30..bf83c39 100644 --- a/tests/unit/test_circuit_breaker.py +++ b/tests/unit/test_circuit_breaker.py @@ -1,11 +1,12 @@ """Unit tests for circuit breaker components.""" import threading -import time +from datetime import timedelta from unittest.mock import MagicMock, patch import pytest import redis +import time_machine from cachekit.reliability.circuit_breaker import ( CacheOperationMetrics, @@ -212,25 +213,26 @@ def test_half_open_transition_after_timeout(self): config = CircuitBreakerConfig(failure_threshold=1, timeout_seconds=0.1) breaker = CircuitBreaker(config, namespace="test") - # Open the circuit - def failing_operation(): - raise BackendError("Connection failed", error_type=BackendErrorType.TRANSIENT) + with time_machine.travel(0, tick=False) as traveller: + # Open the circuit + def failing_operation(): + raise BackendError("Connection failed", error_type=BackendErrorType.TRANSIENT) - with pytest.raises(BackendError): - breaker.call(failing_operation) + with pytest.raises(BackendError): + breaker.call(failing_operation) - assert breaker.state == CircuitState.OPEN + assert breaker.state == CircuitState.OPEN - # Wait for timeout - time.sleep(0.15) + # Advance past timeout + traveller.shift(timedelta(seconds=0.2)) - # Next request should transition to HALF_OPEN and be allowed - def successful_operation(): - return "success" + # Next request should transition to HALF_OPEN and be allowed + def successful_operation(): + return "success" - result = breaker.call(successful_operation) - assert result == "success" - assert breaker.state == CircuitState.HALF_OPEN + result = breaker.call(successful_operation) + assert result == "success" + assert breaker.state == CircuitState.HALF_OPEN def test_half_open_to_closed_after_successes(self): """Test transition from HALF_OPEN to CLOSED after success threshold.""" @@ -244,29 +246,30 @@ def test_half_open_to_closed_after_successes(self): ) breaker = CircuitBreaker(config, namespace="test") - # Open the circuit - def failing_operation(): - raise BackendError("Connection failed", error_type=BackendErrorType.TRANSIENT) + with time_machine.travel(0, tick=False) as traveller: + # Open the circuit + def failing_operation(): + raise BackendError("Connection failed", error_type=BackendErrorType.TRANSIENT) - with pytest.raises(BackendError): - breaker.call(failing_operation) + with pytest.raises(BackendError): + breaker.call(failing_operation) - # Wait and transition to HALF_OPEN - time.sleep(0.15) + # Advance past timeout to trigger HALF_OPEN + traveller.shift(timedelta(seconds=0.2)) - def successful_operation(): - return "success" + def successful_operation(): + return "success" - # First success in HALF_OPEN - result = breaker.call(successful_operation) - assert result == "success" - assert breaker.state == CircuitState.HALF_OPEN - assert breaker.success_count == 1 + # First success in HALF_OPEN + result = breaker.call(successful_operation) + assert result == "success" + assert breaker.state == CircuitState.HALF_OPEN + assert breaker.success_count == 1 - # Second success should close circuit - result = breaker.call(successful_operation) - assert result == "success" - assert breaker.state == CircuitState.CLOSED + # Second success should close circuit + result = breaker.call(successful_operation) + assert result == "success" + assert breaker.state == CircuitState.CLOSED def test_half_open_to_open_on_failure(self): """Test transition from HALF_OPEN back to OPEN on failure.""" @@ -275,21 +278,22 @@ def test_half_open_to_open_on_failure(self): config = CircuitBreakerConfig(failure_threshold=1, timeout_seconds=0.1) breaker = CircuitBreaker(config, namespace="test") - # Open the circuit - def failing_operation(): - raise BackendError("Connection failed", error_type=BackendErrorType.TRANSIENT) + with time_machine.travel(0, tick=False) as traveller: + # Open the circuit + def failing_operation(): + raise BackendError("Connection failed", error_type=BackendErrorType.TRANSIENT) - with pytest.raises(BackendError): - breaker.call(failing_operation) + with pytest.raises(BackendError): + breaker.call(failing_operation) - # Wait and transition to HALF_OPEN - time.sleep(0.15) + # Advance past timeout to trigger HALF_OPEN + traveller.shift(timedelta(seconds=0.2)) - # Any failure in HALF_OPEN should immediately reopen circuit - with pytest.raises(BackendError): - breaker.call(failing_operation) + # Any failure in HALF_OPEN should immediately reopen circuit + with pytest.raises(BackendError): + breaker.call(failing_operation) - assert breaker.state == CircuitState.OPEN + assert breaker.state == CircuitState.OPEN def test_half_open_permit_limiting(self): """Test that HALF_OPEN state limits concurrent requests.""" @@ -298,29 +302,30 @@ def test_half_open_permit_limiting(self): config = CircuitBreakerConfig(failure_threshold=1, timeout_seconds=0.1, half_open_requests=1) breaker = CircuitBreaker(config, namespace="test") - # Open the circuit - def failing_operation(): - raise BackendError("Connection failed", error_type=BackendErrorType.TRANSIENT) + with time_machine.travel(0, tick=False) as traveller: + # Open the circuit + def failing_operation(): + raise BackendError("Connection failed", error_type=BackendErrorType.TRANSIENT) - with pytest.raises(BackendError): - breaker.call(failing_operation) + with pytest.raises(BackendError): + breaker.call(failing_operation) - # Wait for timeout - time.sleep(0.15) + # Advance past timeout + traveller.shift(timedelta(seconds=0.2)) - # First request should be allowed (transitions to HALF_OPEN) - slow_operation = MagicMock(return_value="success") - result = breaker.call(slow_operation) - assert result == "success" - assert breaker.state == CircuitState.HALF_OPEN + # First request should be allowed (transitions to HALF_OPEN) + slow_operation = MagicMock(return_value="success") + result = breaker.call(slow_operation) + assert result == "success" + assert breaker.state == CircuitState.HALF_OPEN - # Additional requests should be rejected (no more permits) - fast_operation = MagicMock() - with pytest.raises(BackendError) as exc_info: - breaker.call(fast_operation) + # Additional requests should be rejected (no more permits) + fast_operation = MagicMock() + with pytest.raises(BackendError) as exc_info: + breaker.call(fast_operation) - assert "Circuit breaker is OPEN" in str(exc_info.value) - fast_operation.assert_not_called() + assert "Circuit breaker is OPEN" in str(exc_info.value) + fast_operation.assert_not_called() @pytest.mark.asyncio async def test_async_call_functionality(self): @@ -392,48 +397,49 @@ def test_thread_safety_state_transitions(self): config = CircuitBreakerConfig(failure_threshold=1, timeout_seconds=0.1) breaker = CircuitBreaker(config, namespace="test") - # Open the circuit first - def failing_operation(): - raise redis.ConnectionError("Connection failed") + with time_machine.travel(0, tick=False) as traveller: + # Open the circuit first + def failing_operation(): + raise redis.ConnectionError("Connection failed") - with pytest.raises(redis.ConnectionError): - breaker.call(failing_operation) + with pytest.raises(redis.ConnectionError): + breaker.call(failing_operation) - assert breaker.state == CircuitState.OPEN + assert breaker.state == CircuitState.OPEN - # Wait for timeout - time.sleep(0.15) + # Advance past timeout + traveller.shift(timedelta(seconds=0.2)) - results = [] - errors = [] + results = [] + errors = [] - def thread_operation(): - try: + def thread_operation(): + try: - def operation(): - return "success" + def operation(): + return "success" - result = breaker.call(operation) - results.append(result) - except Exception as e: - errors.append(e) + result = breaker.call(operation) + results.append(result) + except Exception as e: + errors.append(e) - # Start multiple threads - only one should get through in HALF_OPEN - threads = [] - for _ in range(5): - thread = threading.Thread(target=thread_operation) - threads.append(thread) - thread.start() + # Start multiple threads - only one should get through in HALF_OPEN + threads = [] + for _ in range(5): + thread = threading.Thread(target=thread_operation) + threads.append(thread) + thread.start() - # Wait for all threads to complete - for thread in threads: - thread.join() + # Wait for all threads to complete + for thread in threads: + thread.join() - # Exactly one should succeed (the one that got the permit) - # Others should fail with circuit breaker error - assert len(results) == 1 - assert results[0] == "success" - assert len(errors) == 4 + # Exactly one should succeed (the one that got the permit) + # Others should fail with circuit breaker error + assert len(results) == 1 + assert results[0] == "success" + assert len(errors) == 4 def test_reset_functionality(self): """Test manual reset functionality.""" diff --git a/tests/unit/test_circuit_breaker_race_conditions.py b/tests/unit/test_circuit_breaker_race_conditions.py index e64b348..9793adb 100644 --- a/tests/unit/test_circuit_breaker_race_conditions.py +++ b/tests/unit/test_circuit_breaker_race_conditions.py @@ -9,8 +9,11 @@ import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import timedelta from unittest.mock import MagicMock +import time_machine + from cachekit.reliability import ( CircuitBreaker, CircuitBreakerConfig, @@ -36,58 +39,59 @@ def test_concurrent_open_to_half_open_transition(self): ) breaker = CircuitBreaker(config, namespace="test") - # Force breaker into OPEN state - mock_func = MagicMock(side_effect=Exception("Redis error")) - try: - breaker.call(mock_func) - except Exception: - pass # Expected + with time_machine.travel(0, tick=False) as traveller: + # Force breaker into OPEN state + mock_func = MagicMock(side_effect=Exception("Redis error")) + try: + breaker.call(mock_func) + except Exception: + pass # Expected - # Verify it's in OPEN state - assert breaker._state == CircuitState.OPEN + # Verify it's in OPEN state + assert breaker._state == CircuitState.OPEN - # Wait for timeout to expire - time.sleep(0.2) + # Advance past timeout to expire + traveller.shift(timedelta(seconds=0.25)) - # Track transition attempts from multiple threads - transition_count = 0 - half_open_permits_issued = 0 - transition_lock = threading.Lock() + # Track transition attempts from multiple threads + transition_count = 0 + half_open_permits_issued = 0 + transition_lock = threading.Lock() - def attempt_transition(): - """Attempt to make a request that might trigger transition.""" - nonlocal transition_count, half_open_permits_issued + def attempt_transition(): + """Attempt to make a request that might trigger transition.""" + nonlocal transition_count, half_open_permits_issued - mock_success_func = MagicMock(return_value="success") + mock_success_func = MagicMock(return_value="success") - # Check if request is allowed (triggers potential transition) - try: - _result = breaker.call(mock_success_func) - # If we get here, we were allowed through - with transition_lock: - if breaker._state == CircuitState.HALF_OPEN: - transition_count += 1 - half_open_permits_issued += 1 - return True - except Exception: - # Request was rejected - return False + # Check if request is allowed (triggers potential transition) + try: + _result = breaker.call(mock_success_func) + # If we get here, we were allowed through + with transition_lock: + if breaker._state == CircuitState.HALF_OPEN: + transition_count += 1 + half_open_permits_issued += 1 + return True + except Exception: + # Request was rejected + return False - # Launch multiple threads simultaneously - num_threads = 20 - with ThreadPoolExecutor(max_workers=num_threads) as executor: - futures = [executor.submit(attempt_transition) for _ in range(num_threads)] - results = [future.result() for future in as_completed(futures)] - - # Verify that only one thread successfully transitioned to HALF_OPEN - # and that half-open permits never exceeded the limit - assert transition_count <= 1, f"Multiple transitions detected: {transition_count}" - assert half_open_permits_issued <= config.half_open_requests, ( - f"Half-open permits exceeded limit: {half_open_permits_issued} > {config.half_open_requests}" - ) + # Launch multiple threads simultaneously + num_threads = 20 + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(attempt_transition) for _ in range(num_threads)] + results = [future.result() for future in as_completed(futures)] + + # Verify that only one thread successfully transitioned to HALF_OPEN + # and that half-open permits never exceeded the limit + assert transition_count <= 1, f"Multiple transitions detected: {transition_count}" + assert half_open_permits_issued <= config.half_open_requests, ( + f"Half-open permits exceeded limit: {half_open_permits_issued} > {config.half_open_requests}" + ) - # At least one request should have been allowed through - assert any(results), "No requests were allowed through after timeout" + # At least one request should have been allowed through + assert any(results), "No requests were allowed through after timeout" def test_half_open_permit_limit_under_concurrency(self): """Test that half-open permits never exceed configured limit under concurrent load. @@ -102,65 +106,66 @@ def test_half_open_permit_limit_under_concurrency(self): ) breaker = CircuitBreaker(config, namespace="test") - # Force into OPEN state - mock_func = MagicMock(side_effect=Exception("Redis error")) - try: - breaker.call(mock_func) - except Exception: - pass - - # Wait for timeout - time.sleep(0.2) - - # Manually transition to HALF_OPEN to test permit limiting - with breaker._lock: - breaker._transition_to_half_open() - - assert breaker._state == CircuitState.HALF_OPEN - - # Track permits issued across threads - permits_issued = [] - permits_lock = threading.Lock() - - # Barrier to synchronize thread starts - barrier = threading.Barrier(20) + with time_machine.travel(0, tick=False) as traveller: + # Force into OPEN state + mock_func = MagicMock(side_effect=Exception("Redis error")) + try: + breaker.call(mock_func) + except Exception: + pass - def request_permit(): - """Attempt to get a permit in HALF_OPEN state.""" - # Wait for all threads to be ready - barrier.wait() + # Advance past timeout + traveller.shift(timedelta(seconds=0.25)) - # Test the permit allocation directly + # Manually transition to HALF_OPEN to test permit limiting with breaker._lock: - if breaker._half_open_permits < config.half_open_requests: - _initial_permits = breaker._half_open_permits - breaker._half_open_permits += 1 - with permits_lock: - permits_issued.append(True) - return True - else: - with permits_lock: - permits_issued.append(False) - return False + breaker._transition_to_half_open() - # Launch threads simultaneously to stress test permit limiting - num_threads = 20 - with ThreadPoolExecutor(max_workers=num_threads) as executor: - futures = [executor.submit(request_permit) for _ in range(num_threads)] - results = [future.result() for future in as_completed(futures)] - - # Count successful permits - successful_permits = sum(results) + assert breaker._state == CircuitState.HALF_OPEN - # Verify permit limit was respected - assert successful_permits <= config.half_open_requests, ( - f"Too many permits issued: {successful_permits} > {config.half_open_requests}" - ) + # Track permits issued across threads + permits_issued = [] + permits_lock = threading.Lock() + + # Barrier to synchronize thread starts + barrier = threading.Barrier(20) + + def request_permit(): + """Attempt to get a permit in HALF_OPEN state.""" + # Wait for all threads to be ready + barrier.wait() + + # Test the permit allocation directly + with breaker._lock: + if breaker._half_open_permits < config.half_open_requests: + _initial_permits = breaker._half_open_permits + breaker._half_open_permits += 1 + with permits_lock: + permits_issued.append(True) + return True + else: + with permits_lock: + permits_issued.append(False) + return False + + # Launch threads simultaneously to stress test permit limiting + num_threads = 20 + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(request_permit) for _ in range(num_threads)] + results = [future.result() for future in as_completed(futures)] + + # Count successful permits + successful_permits = sum(results) + + # Verify permit limit was respected + assert successful_permits <= config.half_open_requests, ( + f"Too many permits issued: {successful_permits} > {config.half_open_requests}" + ) - # Verify internal permit counter is consistent - assert breaker._half_open_permits <= config.half_open_requests, ( - f"Internal permit counter exceeded: {breaker._half_open_permits} > {config.half_open_requests}" - ) + # Verify internal permit counter is consistent + assert breaker._half_open_permits <= config.half_open_requests, ( + f"Internal permit counter exceeded: {breaker._half_open_permits} > {config.half_open_requests}" + ) def test_state_consistency_under_concurrent_access(self): """Test that circuit breaker state remains consistent under concurrent access. @@ -294,55 +299,56 @@ def test_timeout_race_condition_prevention(self): ) breaker = CircuitBreaker(config, namespace="test") - # Force into OPEN state - mock_func = MagicMock(side_effect=Exception("Redis error")) - try: - breaker.call(mock_func) - except Exception: - pass + with time_machine.travel(0, tick=False) as traveller: + # Force into OPEN state + mock_func = MagicMock(side_effect=Exception("Redis error")) + try: + breaker.call(mock_func) + except Exception: + pass - assert breaker._state == CircuitState.OPEN + assert breaker._state == CircuitState.OPEN - # Wait for timeout to expire - time.sleep(0.2) + # Advance past timeout to expire + traveller.shift(timedelta(seconds=0.25)) - # Barrier to synchronize thread starts - barrier = threading.Barrier(10) - transitions_attempted = [] - transition_lock = threading.Lock() + # Barrier to synchronize thread starts + barrier = threading.Barrier(10) + transitions_attempted = [] + transition_lock = threading.Lock() - def synchronized_timeout_check(): - """All threads check timeout simultaneously.""" - # Wait for all threads to be ready - barrier.wait() + def synchronized_timeout_check(): + """All threads check timeout simultaneously.""" + # Wait for all threads to be ready + barrier.wait() - # All threads check timeout at the same time - mock_success_func = MagicMock(return_value="success") + # All threads check timeout at the same time + mock_success_func = MagicMock(return_value="success") - try: - _result = breaker.call(mock_success_func) - with transition_lock: - transitions_attempted.append(True) - return True - except Exception: - with transition_lock: - transitions_attempted.append(False) - return False + try: + _result = breaker.call(mock_success_func) + with transition_lock: + transitions_attempted.append(True) + return True + except Exception: + with transition_lock: + transitions_attempted.append(False) + return False - # Start threads simultaneously - with ThreadPoolExecutor(max_workers=10) as executor: - futures = [executor.submit(synchronized_timeout_check) for _ in range(10)] - results = [future.result() for future in as_completed(futures)] + # Start threads simultaneously + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(synchronized_timeout_check) for _ in range(10)] + results = [future.result() for future in as_completed(futures)] - # Only one thread should have been allowed through (transition to HALF_OPEN) - successful_requests = sum(results) - assert successful_requests <= config.half_open_requests, ( - f"Too many requests allowed: {successful_requests} > {config.half_open_requests}" - ) + # Only one thread should have been allowed through (transition to HALF_OPEN) + successful_requests = sum(results) + assert successful_requests <= config.half_open_requests, ( + f"Too many requests allowed: {successful_requests} > {config.half_open_requests}" + ) - # Circuit should be in HALF_OPEN state if any request was allowed - if successful_requests > 0: - assert breaker._state == CircuitState.HALF_OPEN + # Circuit should be in HALF_OPEN state if any request was allowed + if successful_requests > 0: + assert breaker._state == CircuitState.HALF_OPEN def test_circuit_breaker_allow_request_race_condition(self): """Test that CircuitBreaker._allow_request() prevents race conditions. @@ -362,61 +368,62 @@ def test_circuit_breaker_allow_request_race_condition(self): ) breaker = CircuitBreaker(config, namespace="test_allow_request") - # Force circuit to OPEN state - breaker._on_failure(Exception("Test failure")) - assert breaker.state == CircuitState.OPEN + with time_machine.travel(0, tick=False) as traveller: + # Force circuit to OPEN state + breaker._on_failure(Exception("Test failure")) + assert breaker.state == CircuitState.OPEN - # Wait for recovery timeout to expire - time.sleep(0.15) + # Advance past recovery timeout + traveller.shift(timedelta(seconds=0.2)) - # Track transition attempts - transition_attempts = [] - allowed_requests = [] - attempt_lock = threading.Lock() + # Track transition attempts + transition_attempts = [] + allowed_requests = [] + attempt_lock = threading.Lock() - def concurrent_allow_request(thread_id): - """Each thread attempts to call _allow_request().""" - # Capture initial state - initial_state = breaker.state - - # Call _allow_request() which should handle race conditions - allowed = breaker._allow_request() + def concurrent_allow_request(thread_id): + """Each thread attempts to call _allow_request().""" + # Capture initial state + initial_state = breaker.state - # Capture final state - final_state = breaker.state - - with attempt_lock: - transition_attempts.append( - {"thread_id": thread_id, "initial_state": initial_state, "final_state": final_state, "allowed": allowed} - ) - if allowed: - allowed_requests.append(thread_id) - - return allowed + # Call _allow_request() which should handle race conditions + allowed = breaker._allow_request() - # Launch many threads simultaneously - num_threads = 50 - with ThreadPoolExecutor(max_workers=num_threads) as executor: - futures = [executor.submit(concurrent_allow_request, i) for i in range(num_threads)] - _results = [future.result() for future in as_completed(futures)] + # Capture final state + final_state = breaker.state - # Analyze results - transitions_to_half_open = sum( - 1 - for attempt in transition_attempts - if attempt["initial_state"] == CircuitState.OPEN and attempt["final_state"] == CircuitState.HALF_OPEN - ) + with attempt_lock: + transition_attempts.append( + {"thread_id": thread_id, "initial_state": initial_state, "final_state": final_state, "allowed": allowed} + ) + if allowed: + allowed_requests.append(thread_id) + + return allowed + + # Launch many threads simultaneously + num_threads = 50 + with ThreadPoolExecutor(max_workers=num_threads) as executor: + futures = [executor.submit(concurrent_allow_request, i) for i in range(num_threads)] + _results = [future.result() for future in as_completed(futures)] + + # Analyze results + transitions_to_half_open = sum( + 1 + for attempt in transition_attempts + if attempt["initial_state"] == CircuitState.OPEN and attempt["final_state"] == CircuitState.HALF_OPEN + ) - # Verify only one thread transitioned the state - assert transitions_to_half_open <= 1, f"Multiple threads ({transitions_to_half_open}) transitioned to HALF_OPEN!" + # Verify only one thread transitioned the state + assert transitions_to_half_open <= 1, f"Multiple threads ({transitions_to_half_open}) transitioned to HALF_OPEN!" - # Verify permits didn't exceed limit - assert len(allowed_requests) <= config.half_open_requests, ( - f"Too many requests allowed: {len(allowed_requests)} > {config.half_open_requests}" - ) + # Verify permits didn't exceed limit + assert len(allowed_requests) <= config.half_open_requests, ( + f"Too many requests allowed: {len(allowed_requests)} > {config.half_open_requests}" + ) - # Verify final state is HALF_OPEN - assert breaker.state == CircuitState.HALF_OPEN + # Verify final state is HALF_OPEN + assert breaker.state == CircuitState.HALF_OPEN def test_circuit_breaker_half_open_total_attempts_tracking(self): """Test that half_open_total_attempts correctly limits total test requests. diff --git a/uv.lock b/uv.lock index 0ca8608..ed4eeb2 100644 --- a/uv.lock +++ b/uv.lock @@ -298,6 +298,8 @@ dev = [ { name = "pyyaml" }, { name = "requests", version = "2.33.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "ruff" }, + { name = "time-machine", version = "2.19.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "time-machine", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] fuzz = [ { name = "atheris" }, @@ -348,6 +350,7 @@ dev = [ { name = "pyyaml", specifier = ">=6.0.3" }, { name = "requests", marker = "python_full_version >= '3.10'", specifier = ">=2.33.0" }, { name = "ruff", specifier = ">=0.6.0" }, + { name = "time-machine", specifier = ">=2.19.0" }, ] fuzz = [{ name = "atheris", specifier = ">=2.3.0" }] @@ -2546,6 +2549,198 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, ] +[[package]] +name = "time-machine" +version = "2.19.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/1b5fdd165f61b67f445fac2a7feb0c655118edef429cd09ff5a8067f7f1d/time_machine-2.19.0.tar.gz", hash = "sha256:7c5065a8b3f2bbb449422c66ef71d114d3f909c276a6469642ecfffb6a0fcd29", size = 14576, upload-time = "2025-08-19T17:22:08.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8f/19125611ebbcb3a14da14cd982b9eb4573e2733db60c9f1fbf6a39534f40/time_machine-2.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b5169018ef47206997b46086ce01881cd3a4666fd2998c9d76a87858ca3e49e9", size = 19659, upload-time = "2025-08-19T17:20:30.062Z" }, + { url = "https://files.pythonhosted.org/packages/74/da/9b0a928321e7822a3ff96dbd1eae089883848e30e9e1b149b85fb96ba56b/time_machine-2.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85bb7ed440fccf6f6d0c8f7d68d849e7c3d1f771d5e0b2cdf871fa6561da569f", size = 15157, upload-time = "2025-08-19T17:20:31.931Z" }, + { url = "https://files.pythonhosted.org/packages/36/ff/d7e943422038f5f2161fe2c2d791e64a45be691ef946020b20f3a6efc4d4/time_machine-2.19.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a3b12028af1cdc09ccd595be2168b7b26f206c1e190090b048598fbe278beb8e", size = 32860, upload-time = "2025-08-19T17:20:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/fc/80/2b0f1070ed9808ee7da7a6da62a4a0b776957cb4d861578348f86446e778/time_machine-2.19.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c261f073086cf081d1443cbf7684148c662659d3d139d06b772bfe3fe7cc71a6", size = 34510, upload-time = "2025-08-19T17:20:34.221Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b4/48038691c8d89924b36c83335a73adeeb68c884f5a1da08a5b17b8a956f3/time_machine-2.19.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:011954d951230a9f1079f22b39ed1a3a9abb50ee297dfb8c557c46351659d94d", size = 36204, upload-time = "2025-08-19T17:20:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/60e8adb541df195e83cb74b720b2cfb1f22ed99c5a7f8abf2a9ab3442cb5/time_machine-2.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b0f83308b29c7872006803f2e77318874eb84d0654f2afe0e48e3822e7a2e39b", size = 34936, upload-time = "2025-08-19T17:20:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/72/e8cee59c6cd99dd3b25b8001a0253e779a286aa8f44d5b40777cbd66210b/time_machine-2.19.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:39733ef844e2984620ec9382a42d00cccc4757d75a5dd572be8c2572e86e50b9", size = 32932, upload-time = "2025-08-19T17:20:37.901Z" }, + { url = "https://files.pythonhosted.org/packages/2c/eb/83f300d93c1504965d944e03679f1c943a923bce2d0fdfadef0e2e22cc13/time_machine-2.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8db99f6334432e9ffbf00c215caf2ae9773f17cec08304d77e9e90febc3507b", size = 34010, upload-time = "2025-08-19T17:20:39.202Z" }, + { url = "https://files.pythonhosted.org/packages/e1/77/f35f2500e04daac5033a22fbfd17e68467822b8406ee77966bf222ccaa26/time_machine-2.19.0-cp310-cp310-win32.whl", hash = "sha256:72bf66cd19e27ffd26516b9cbe676d50c2e0b026153289765dfe0cf406708128", size = 17121, upload-time = "2025-08-19T17:20:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/db/df/32d3e0404be1760a64a44caab2af34b07e952bfe00a23134fea9ddba3e8a/time_machine-2.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:46f1c945934ce3d6b4f388b8e581fce7f87ec891ea90d7128e19520e434f96f0", size = 17957, upload-time = "2025-08-19T17:20:41.079Z" }, + { url = "https://files.pythonhosted.org/packages/66/df/598a71a1afb4b509a4587273b76590b16d9110a3e9106f01eedc68d02bb2/time_machine-2.19.0-cp310-cp310-win_arm64.whl", hash = "sha256:fb4897c7a5120a4fd03f0670f332d83b7e55645886cd8864a71944c4c2e5b35b", size = 16821, upload-time = "2025-08-19T17:20:41.967Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ed/4815ebcc9b6c14273f692b9be38a9b09eae52a7e532407cc61a51912b121/time_machine-2.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5ee91664880434d98e41585c3446dac7180ec408c786347451ddfca110d19296", size = 19342, upload-time = "2025-08-19T17:20:43.207Z" }, + { url = "https://files.pythonhosted.org/packages/ee/08/154cce8b11b60d8238b0b751b8901d369999f4e8f7c3a5f917caa5d95b0b/time_machine-2.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ed3732b83a893d1c7b8cabde762968b4dc5680ee0d305b3ecca9bb516f4e3862", size = 14978, upload-time = "2025-08-19T17:20:44.134Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/b689d8c8eeca7af375cfcd64973e49e83aa817cc00f80f98548d42c0eb50/time_machine-2.19.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6ba0303e9cc9f7f947e344f501e26bedfb68fab521e3c2729d370f4f332d2d55", size = 30964, upload-time = "2025-08-19T17:20:45.366Z" }, + { url = "https://files.pythonhosted.org/packages/80/91/38bf9c79674e95ce32e23c267055f281dff651eec77ed32a677db3dc011a/time_machine-2.19.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2851825b524a988ee459c37c1c26bdfaa7eff78194efb2b562ea497a6f375b0a", size = 32606, upload-time = "2025-08-19T17:20:46.693Z" }, + { url = "https://files.pythonhosted.org/packages/19/4a/e9222d85d4de68975a5e799f539a9d32f3a134a9101fca0a61fa6aa33d68/time_machine-2.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68d32b09ecfd7fef59255c091e8e7c24dd117f882c4880b5c7ab8c5c32a98f89", size = 34405, upload-time = "2025-08-19T17:20:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/09480d608d42d6876f9ff74593cfc9197a7eb2c31381a74fb2b145575b65/time_machine-2.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60c46ab527bf2fa144b530f639cc9e12803524c9e1f111dc8c8f493bb6586eeb", size = 33181, upload-time = "2025-08-19T17:20:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/84/64/f9359e000fad32d9066305c48abc527241d608bcdf77c19d67d66e268455/time_machine-2.19.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:56f26ab9f0201c453d18fe76bb7d1cf05fe58c1b9d9cb0c7d243d05132e01292", size = 31036, upload-time = "2025-08-19T17:20:50.276Z" }, + { url = "https://files.pythonhosted.org/packages/71/0d/fab2aacec71e3e482bd7fce0589381f9414a4a97f8766bddad04ad047b7b/time_machine-2.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6c806cf3c1185baa1d807b7f51bed0db7a6506832c961d5d1b4c94c775749bc0", size = 32145, upload-time = "2025-08-19T17:20:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/44/fb/faeba2405fb27553f7b28db441a500e2064ffdb2dcba001ee315fdd2c121/time_machine-2.19.0-cp311-cp311-win32.whl", hash = "sha256:b30039dfd89855c12138095bee39c540b4633cbc3684580d684ef67a99a91587", size = 17004, upload-time = "2025-08-19T17:20:52.38Z" }, + { url = "https://files.pythonhosted.org/packages/2f/84/87e483d660ca669426192969280366635c845c3154a9fe750be546ed3afc/time_machine-2.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:13ed8b34430f1de79905877f5600adffa626793ab4546a70a99fb72c6a3350d8", size = 17822, upload-time = "2025-08-19T17:20:53.348Z" }, + { url = "https://files.pythonhosted.org/packages/41/f4/ebf7bbf5047854a528adaf54a5e8780bc5f7f0104c298ab44566a3053bf8/time_machine-2.19.0-cp311-cp311-win_arm64.whl", hash = "sha256:cc29a50a0257d8750b08056b66d7225daab47606832dea1a69e8b017323bf511", size = 16680, upload-time = "2025-08-19T17:20:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/9b/aa/7e00614d339e4d687f6e96e312a1566022528427d237ec639df66c4547bc/time_machine-2.19.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c85cf437dc3c07429456d8d6670ac90ecbd8241dcd0fbf03e8db2800576f91ff", size = 19308, upload-time = "2025-08-19T17:20:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/ab/3c/bde3c757394f5bca2fbc1528d4117960a26c38f9b160bf471b38d2378d8f/time_machine-2.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9238897e8ef54acdf59f5dff16f59ca0720e7c02d820c56b4397c11db5d3eb9", size = 15019, upload-time = "2025-08-19T17:20:56.204Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e0/8ca916dd918018352d377f1f5226ee071cfbeb7dbbde2b03d14a411ac2b1/time_machine-2.19.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e312c7d5d6bfffb96c6a7b39ff29e3046de100d7efaa3c01552654cfbd08f14c", size = 33079, upload-time = "2025-08-19T17:20:57.166Z" }, + { url = "https://files.pythonhosted.org/packages/48/69/184a0209f02dd0cb5e01e8d13cd4c97a5f389c4e3d09b95160dd676ad1e7/time_machine-2.19.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:714c40b2c90d1c57cc403382d5a9cf16e504cb525bfe9650095317da3c3d62b5", size = 34925, upload-time = "2025-08-19T17:20:58.117Z" }, + { url = "https://files.pythonhosted.org/packages/43/42/4bbf4309e8e57cea1086eb99052d97ff6ddecc1ab6a3b07aa4512f8bf963/time_machine-2.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eaa1c675d500dc3ccae19e9fb1feff84458a68c132bbea47a80cc3dd2df7072", size = 36384, upload-time = "2025-08-19T17:20:59.108Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/9f510dc1719157348c1a2e87423aed406589070b54b503cb237d9bf3a4fe/time_machine-2.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e77a414e9597988af53b2b2e67242c9d2f409769df0d264b6d06fda8ca3360d4", size = 34881, upload-time = "2025-08-19T17:21:00.116Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/61764a635c70cc76c76ba582dfdc1a84834cddaeb96789023af5214426b2/time_machine-2.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd93996970e11c382b04d4937c3cd0b0167adeef14725ece35aae88d8a01733c", size = 32931, upload-time = "2025-08-19T17:21:01.095Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e0/f028d93b266e6ade8aca5851f76ebbc605b2905cdc29981a2943b43e1a6c/time_machine-2.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8e20a6d8d6e23174bd7e931e134d9610b136db460b249d07e84ecdad029ec352", size = 34241, upload-time = "2025-08-19T17:21:02.052Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a6/36d1950ed1d3f613158024cf1dcc73db1d9ef0b9117cf51ef2e37dc06499/time_machine-2.19.0-cp312-cp312-win32.whl", hash = "sha256:95afc9bc65228b27be80c2756799c20b8eb97c4ef382a9b762b6d7888bc84099", size = 17021, upload-time = "2025-08-19T17:21:03.374Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0d/e2dce93355abda3cac69e77fe96566757e98b8fe7fdcbddce89c9ced3f5f/time_machine-2.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:e84909af950e2448f4e2562ea5759c946248c99ab380d2b47d79b62bd76fa236", size = 17857, upload-time = "2025-08-19T17:21:04.331Z" }, + { url = "https://files.pythonhosted.org/packages/eb/28/50ae6fb83b7feeeca7a461c0dc156cf7ef5e6ef594a600d06634fde6a2cb/time_machine-2.19.0-cp312-cp312-win_arm64.whl", hash = "sha256:0390a1ea9fa7e9d772a39b7c61b34fdcca80eb9ffac339cc0441c6c714c81470", size = 16677, upload-time = "2025-08-19T17:21:05.39Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b8/24ebce67aa531bae2cbe164bb3f4abc6467dc31f3aead35e77f5a075ea3e/time_machine-2.19.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5e172866753e6041d3b29f3037dc47c20525176a494a71bbd0998dfdc4f11f2f", size = 19373, upload-time = "2025-08-19T17:21:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/53/a5/c9a5240fd2f845d3ff9fa26f8c8eaa29f7239af9d65007e61d212250f15b/time_machine-2.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f70f68379bd6f542ae6775cce9a4fa3dcc20bf7959c42eaef871c14469e18097", size = 15056, upload-time = "2025-08-19T17:21:07.667Z" }, + { url = "https://files.pythonhosted.org/packages/b9/92/66cce5d2fb2a5e68459aca85fd18a7e2d216f725988940cd83f96630f2f1/time_machine-2.19.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e69e0b0f694728a00e72891ef8dd00c7542952cb1c87237db594b6b27d504a96", size = 33172, upload-time = "2025-08-19T17:21:08.619Z" }, + { url = "https://files.pythonhosted.org/packages/ae/20/b499e9ab4364cd466016c33dcdf4f56629ca4c20b865bd4196d229f31d92/time_machine-2.19.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3ae0a8b869574301ec5637e32c270c7384cca5cd6e230f07af9d29271a7fa293", size = 35042, upload-time = "2025-08-19T17:21:09.622Z" }, + { url = "https://files.pythonhosted.org/packages/41/32/b252d3d32791eb16c07d553c820dbc33d9c7fa771de3d1c602190bded2b7/time_machine-2.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:554e4317de90e2f7605ff80d153c8bb56b38c0d0c0279feb17e799521e987b8c", size = 36535, upload-time = "2025-08-19T17:21:10.571Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/4d0470062b9742e1b040ab81bad04d1a5d1de09806507bb6188989cfa1a7/time_machine-2.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6567a5ec5538ed550539ac29be11b3cb36af1f9894e2a72940cba0292cc7c3c9", size = 34945, upload-time = "2025-08-19T17:21:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/24/71/2f741b29d98b1c18f6777a32236497c3d3264b6077e431cea4695684c8a1/time_machine-2.19.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82e9ffe8dfff07b0d810a2ad015a82cd78c6a237f6c7cf185fa7f747a3256f8a", size = 33014, upload-time = "2025-08-19T17:21:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/ca8dba6106562843fd99f672e5aaf95badbc10f4f13f7cfe8d8640a7019d/time_machine-2.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e1c4e578cdd69b3531d8dd3fbcb92a0cd879dadb912ee37af99c3a9e3c0d285", size = 34350, upload-time = "2025-08-19T17:21:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/21/7f/34fe540450e18d0a993240100e4b86e8d03d831b92af8bb6ddb2662dc6fc/time_machine-2.19.0-cp313-cp313-win32.whl", hash = "sha256:72dbd4cbc3d96dec9dd281ddfbb513982102776b63e4e039f83afb244802a9e5", size = 17047, upload-time = "2025-08-19T17:21:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/c8be73df82c7ebe7cd133279670e89b8b110af3ce1412c551caa9d08e625/time_machine-2.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:e17e3e089ac95f9a145ce07ff615e3c85674f7de36f2d92aaf588493a23ffb4b", size = 17868, upload-time = "2025-08-19T17:21:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/2dfd3b8fb285308f61cd7aa9bfa96f46ddf916e3549a0f0afd094c556599/time_machine-2.19.0-cp313-cp313-win_arm64.whl", hash = "sha256:149072aff8e3690e14f4916103d898ea0d5d9c95531b6aa0995251c299533f7b", size = 16710, upload-time = "2025-08-19T17:21:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/05/c1/deebb361727d2c5790f9d4d874be1b19afd41f4375581df465e6718b46a2/time_machine-2.19.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f3589fee1ed0ab6ee424a55b0ea1ec694c4ba64cc26895bcd7d99f3d1bc6a28a", size = 20053, upload-time = "2025-08-19T17:21:17.704Z" }, + { url = "https://files.pythonhosted.org/packages/45/e8/fe3376951e6118d8ec1d1f94066a169b791424fe4a26c7dfc069b153ee08/time_machine-2.19.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7887e85275c4975fe54df03dcdd5f38bd36be973adc68a8c77e17441c3b443d6", size = 15423, upload-time = "2025-08-19T17:21:18.668Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/f88d95cd1a87c650cf3749b4d64afdaf580297aa18ad7f4b44ec9d252dfc/time_machine-2.19.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ce0be294c209928563fcce1c587963e60ec803436cf1e181acd5bc1e425d554b", size = 39630, upload-time = "2025-08-19T17:21:19.645Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5d/65a5c48a65357e56ec6f032972e4abd1c02d4fca4b0717a3aaefd19014d4/time_machine-2.19.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a62fd1ab380012c86f4c042010418ed45eb31604f4bf4453e17c9fa60bc56a29", size = 41242, upload-time = "2025-08-19T17:21:20.979Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/fe5209e1615fde0a8cad6c4e857157b150333ed1fe31a7632b08cfe0ebdd/time_machine-2.19.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b25ec853a4530a5800731257f93206b12cbdee85ede964ebf8011b66086a7914", size = 44278, upload-time = "2025-08-19T17:21:21.984Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3a/a5e5fe9c5d614cde0a9387ff35e8dfd12c5ef6384e4c1a21b04e6e0b905d/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a430e4d0e0556f021a9c78e9b9f68e5e8910bdace4aa34ed4d1a73e239ed9384", size = 42321, upload-time = "2025-08-19T17:21:23.755Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c5/56eca774e9162bc1ce59111d2bd69140dc8908c9478c92ec7bd15d547600/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2415b7495ec4364c8067071e964fbadfe746dd4cdb43983f2f0bd6ebed13315c", size = 39270, upload-time = "2025-08-19T17:21:26.009Z" }, + { url = "https://files.pythonhosted.org/packages/9b/69/5dd0c420667578169a12acc8c8fd7452e8cfb181e41c9b4ac7e88fa36686/time_machine-2.19.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbfc6b90c10f288594e1bf89a728a98cc0030791fd73541bbdc6b090aff83143", size = 40193, upload-time = "2025-08-19T17:21:27.054Z" }, + { url = "https://files.pythonhosted.org/packages/75/a7/de974d421bd55c9355583427c2a38fb0237bb5fd6614af492ba89dacb2f9/time_machine-2.19.0-cp313-cp313t-win32.whl", hash = "sha256:16f5d81f650c0a4d117ab08036dc30b5f8b262e11a4a0becc458e7f1c011b228", size = 17542, upload-time = "2025-08-19T17:21:28.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/aa0d05becd5d06ae8d3f16d657dc8cc9400c8d79aef80299de196467ff12/time_machine-2.19.0-cp313-cp313t-win_amd64.whl", hash = "sha256:645699616ec14e147094f601e6ab9553ff6cea37fad9c42720a6d7ed04bcd5dc", size = 18703, upload-time = "2025-08-19T17:21:29.663Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c0/f785a4c7c73aa176510f7c48b84b49c26be84af0d534deb222e0327f750e/time_machine-2.19.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b32daa965d13237536ea3afaa5ad61ade2b2d9314bc3a20196a0d2e1d7b57c6a", size = 17020, upload-time = "2025-08-19T17:21:30.653Z" }, + { url = "https://files.pythonhosted.org/packages/ed/97/c5fb51def06c0b2b6735332ad118ab35b4d9b85368792e5b638e99b1b686/time_machine-2.19.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:31cb43c8fd2d961f31bed0ff4e0026964d2b35e5de9e0fabbfecf756906d3612", size = 19360, upload-time = "2025-08-19T17:21:31.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4e/2d795f7d6b7f5205ffe737a05bb1cf19d8038233b797062b2ef412b8512b/time_machine-2.19.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:bdf481a75afc6bff3e520db594501975b652f7def21cd1de6aa971d35ba644e6", size = 15033, upload-time = "2025-08-19T17:21:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/dd/32/9bad501e360b4e758c58fae616ca5f8c7ad974b343f2463a15b2bf77a366/time_machine-2.19.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:00bee4bb950ac6a08d62af78e4da0cf2b4fc2abf0de2320d0431bf610db06e7c", size = 33379, upload-time = "2025-08-19T17:21:33.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/45/eda0ca4d793dfd162478d6163759b1c6ce7f6e61daa7fd7d62b31f21f87f/time_machine-2.19.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9f02199490906582302ce09edd32394fb393271674c75d7aa76c7a3245f16003", size = 35123, upload-time = "2025-08-19T17:21:34.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/97e16325442ae5731fcaac794f0a1ef9980eff8a5491e58201d7eb814a34/time_machine-2.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e35726c7ba625f844c13b1fc0d4f81f394eefaee1d3a094a9093251521f2ef15", size = 36588, upload-time = "2025-08-19T17:21:35.975Z" }, + { url = "https://files.pythonhosted.org/packages/e8/9d/bf0b2ccc930cc4a316f26f1c78d3f313cd0fa13bb7480369b730a8f129db/time_machine-2.19.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:304315023999cd401ff02698870932b893369e1cfeb2248d09f6490507a92e97", size = 35013, upload-time = "2025-08-19T17:21:37.017Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/39ac6a3078174f9715d88364871348b249631f12e76de1b862433b3f8862/time_machine-2.19.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9765d4f003f263ea8bfd90d2d15447ca4b3dfa181922cf6cf808923b02ac180a", size = 33303, upload-time = "2025-08-19T17:21:38.352Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ac/d8646baf9f95f2e792a6d7a7b35e92fca253c4a992afff801beafae0e5c2/time_machine-2.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7837ef3fd5911eb9b480909bb93d922737b6bdecea99dfcedb0a03807de9b2d3", size = 34440, upload-time = "2025-08-19T17:21:39.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8b/8b6568c5ae966d80ead03ab537be3c6acf2af06fb501c2d466a3162c6295/time_machine-2.19.0-cp314-cp314-win32.whl", hash = "sha256:4bb5bd43b1bdfac3007b920b51d8e761f024ed465cfeec63ac4296922a4ec428", size = 17162, upload-time = "2025-08-19T17:21:40.381Z" }, + { url = "https://files.pythonhosted.org/packages/46/a5/211c1ab4566eba5308b2dc001b6349e3a032e3f6afa67ca2f27ea6b27af5/time_machine-2.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:f583bbd0aa8ab4a7c45a684bf636d9e042d466e30bcbae1d13e7541e2cbe7207", size = 18040, upload-time = "2025-08-19T17:21:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/4c2fb705f6371cb83824da45a8b967514a922fc092a0ef53979334d97a70/time_machine-2.19.0-cp314-cp314-win_arm64.whl", hash = "sha256:f379c6f8a6575a8284592179cf528ce89373f060301323edcc44f1fa1d37be12", size = 16752, upload-time = "2025-08-19T17:21:42.336Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/6437d18f31c666b5116c97572a282ac2590a82a0a9867746a6647eaf4613/time_machine-2.19.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a3b8981f9c663b0906b05ab4d0ca211fae4b63b47c6ec26de5374fe56c836162", size = 20057, upload-time = "2025-08-19T17:21:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a2/e03639ec2ba7200328bbcad8a2b2b1d5fccca9cceb9481b164a1cabdcb33/time_machine-2.19.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e9c6363893e7f52c226afbebb23e825259222d100e67dfd24c8a6d35f1a1907", size = 15430, upload-time = "2025-08-19T17:21:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ff/39e63a48e840f3e36ce24846ee51dd99c6dba635659b1750a2993771e88e/time_machine-2.19.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:206fcd6c9a6f00cac83db446ad1effc530a8cec244d2780af62db3a2d0a9871b", size = 39622, upload-time = "2025-08-19T17:21:45.821Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/ee5ac79c4954768705801e54817c7d58e07e25a0bb227e775f501f3e2122/time_machine-2.19.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf33016a1403c123373ffaeff25e26e69d63bf2c63b6163932efed94160db7ef", size = 41235, upload-time = "2025-08-19T17:21:46.783Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3e/9af5f39525e779185c77285b8bbae15340eeeaa0afb33d458bc8b47d459b/time_machine-2.19.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9247c4bb9bbd3ff584ef4efbdec8efd9f37aa08bcfc4728bde1e489c2cb445bd", size = 44276, upload-time = "2025-08-19T17:21:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/572c7443cc27140bbeae3947279bbd4a120f9e8622253a20637f260b7813/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:77f9bb0b86758d1f2d9352642c874946ad5815df53ef4ca22eb9d532179fe50d", size = 42330, upload-time = "2025-08-19T17:21:48.881Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/1a81c2e08ee7dae13ec8ceed27a29afa980c3d63852e42f1e023bf0faa03/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0b529e262df3b9c449f427385f4d98250828c879168c2e00eec844439f40b370", size = 39281, upload-time = "2025-08-19T17:21:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/d2/60/6f0d6e5108978ca1a2a4ffb4d1c7e176d9199bb109fd44efe2680c60b52a/time_machine-2.19.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9199246e31cdc810e5d89cb71d09144c4d745960fdb0824da4994d152aca3303", size = 40201, upload-time = "2025-08-19T17:21:50.953Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/3ea4951e8293b0643feb98c0b9a176fa822154f1810835db3f282968ab10/time_machine-2.19.0-cp314-cp314t-win32.whl", hash = "sha256:0fe81bae55b7aefc2c2a34eb552aa82e6c61a86b3353a3c70df79b9698cb02ca", size = 17743, upload-time = "2025-08-19T17:21:51.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8b/cd802884ca8a98e2b6cdc2397d57dd12ff8a7d1481e06fc3fad3d4e7e5ff/time_machine-2.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7253791b8d7e7399fbeed7a8193cb01bc004242864306288797056badbdaf80b", size = 18956, upload-time = "2025-08-19T17:21:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/c6/49/cabb1593896082fd55e34768029b8b0ca23c9be8b2dc127e0fc14796d33e/time_machine-2.19.0-cp314-cp314t-win_arm64.whl", hash = "sha256:536bd1ac31ab06a1522e7bf287602188f502dc19d122b1502c4f60b1e8efac79", size = 17068, upload-time = "2025-08-19T17:21:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/0608376c3167afe6cf7cdfd2b05c142ea4c42616eee9ba06d1799965806a/time_machine-2.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8bb00b30ec9fe56d01e9812df1ffe39f331437cef9bfaebcc81c83f7f8f8ee2", size = 19659, upload-time = "2025-08-19T17:21:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/11/c4/72eb8c7b36830cf36c51d7bc2f1ac313d68881c3a58040fb6b42c4523d20/time_machine-2.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d821c60efc08a97cc11e5482798e6fd5eba5c0f22a02db246b50895dbdc0de41", size = 15153, upload-time = "2025-08-19T17:21:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/89/1a/0782e1f5c8ab8809ebd992709e1bb69d67600191baa023af7a5d32023a3c/time_machine-2.19.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb051aec7b3b6e96a200d911c225901e6133ff3da11e470e24111a53bbc13637", size = 32555, upload-time = "2025-08-19T17:21:57.74Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/8ef58e2f6321851d5900ca3d18044938832c2ed42a2ac7570ca6aa29768a/time_machine-2.19.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe59909d95a2ef5e01ce3354fdea3908404c2932c2069f00f66dff6f27e9363e", size = 34185, upload-time = "2025-08-19T17:21:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/82/74/ce0c9867f788c1fb22c417ec1aae47a24117e53d51f6ff97d7c6ca5392f6/time_machine-2.19.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29e84b8682645b16eb6f9e8ec11c35324ad091841a11cf4fc3fc7f6119094c89", size = 35917, upload-time = "2025-08-19T17:22:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/d2/70/6f97a8f552dbaa66feb10170b5726dab74bc531673d1ed9d6f271547e54c/time_machine-2.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a11f1c0e0d06023dc01614c964e256138913551d3ae6dca5148f79081156336", size = 34584, upload-time = "2025-08-19T17:22:01.447Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/cf139088ce537c15d7f03cf56ec317d3a5cfb520e30aa711ea0248d0ae8a/time_machine-2.19.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:57a235a6307c54df50e69f1906e2f199e47da91bde4b886ee05aff57fe4b6bf6", size = 32608, upload-time = "2025-08-19T17:22:02.548Z" }, + { url = "https://files.pythonhosted.org/packages/b1/17/0ec41ef7a30c6753fb226a28b74162b264b35724905ced4098f2f5076ded/time_machine-2.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:426aba552f7af9604adad9ef570c859af7c1081d878db78089fac159cd911b0a", size = 33686, upload-time = "2025-08-19T17:22:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/586f15159083ec84f178d494c60758c46603b00c9641b04deb63f1950128/time_machine-2.19.0-cp39-cp39-win32.whl", hash = "sha256:67772c7197a3a712d1b970ed545c6e98db73524bd90e245fd3c8fa7ad7630768", size = 17133, upload-time = "2025-08-19T17:22:04.989Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c2/bfe4b906a9fe0bf2d011534314212ed752d6b8f392c9c82f6ac63dccc5ab/time_machine-2.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:011d7859089263204dc5fdf83dce7388f986fe833c9381d6106b4edfda2ebd3e", size = 17972, upload-time = "2025-08-19T17:22:06.026Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/182343eba05aa5787732aaa68f3b3feb5e40ddf86b928ae941be45646393/time_machine-2.19.0-cp39-cp39-win_arm64.whl", hash = "sha256:e1af66550fa4685434f00002808a525f176f1f92746646c0019bb86fbff48b27", size = 16820, upload-time = "2025-08-19T17:22:07.227Z" }, +] + +[[package]] +name = "time-machine" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/31/6bf41cb4a326230518d9b76c910dfc11d4fc23444d1cbfdf2d7652bd99f4/time_machine-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:68142c070e78b62215d8029ec7394905083a4f9aacb0a2a11514ce70b5951b13", size = 19447, upload-time = "2025-12-17T23:31:30.181Z" }, + { url = "https://files.pythonhosted.org/packages/fa/14/d71ce771712e1cbfa15d8c24452225109262b16cb6caaf967e9f60662b67/time_machine-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:161bbd0648802ffdfcb4bb297ecb26b3009684a47d3a4dedb90bc549df4fa2ad", size = 15432, upload-time = "2025-12-17T23:31:31.381Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/dcb43a11f8029561996fad58ff9d3dc5e6d7f32b74f0745a2965d7e4b4f3/time_machine-3.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1359ba8c258be695ba69253bc84db882fd616fe69b426cc6056536da2c7bf68e", size = 32956, upload-time = "2025-12-17T23:31:32.469Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/d802cd3c335c414f9b11b479f7459aa72df5de6485c799966cfdf8856d53/time_machine-3.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c85b169998ca2c24a78fb214586ec11c4cad56d9c38f55ad8326235cb481c884", size = 34556, upload-time = "2025-12-17T23:31:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/85/ee/51ad553514ab0b940c7c82c6e1519dd10fd06ac07b32039a1d153ef09c88/time_machine-3.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65b9367cb8a10505bc8f67da0da514ba20fa816fc47e11f434f7c60350322b4c", size = 36101, upload-time = "2025-12-17T23:31:35.462Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/938b111b5bb85a2b07502d0f9d8a704fc75bd760d62e76bce23c89ed16c9/time_machine-3.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9faca6a0f1973d7df3233c951fc2a11ff0c54df74087d8aaf41ae3deb19d0893", size = 34905, upload-time = "2025-12-17T23:31:36.543Z" }, + { url = "https://files.pythonhosted.org/packages/dd/50/0951f73b23e76455de0b4a3a58ac5a24bd8d10489624b1c5e03f10c6fc0b/time_machine-3.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:213b1ada7f385d467e598999b642eda4a8e89ae10ad5dc4f5d8f672cbf604261", size = 33012, upload-time = "2025-12-17T23:31:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/4f/95/5304912d3dcecc4e14ed222dbe0396352efdf8497534abc3c9edd67a7528/time_machine-3.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:160b6afd94c39855af04d39c58e4cf602406abd6d79427ab80e830ea71789cfb", size = 34104, upload-time = "2025-12-17T23:31:39.449Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/af56518652ec7adac4ced193b7a42c4ff354fef28a412b3b5ffa5763aead/time_machine-3.2.0-cp310-cp310-win32.whl", hash = "sha256:c15d9ac257c78c124d112e4fc91fa9f3dcb004bdda913c19f0e7368d713cf080", size = 17468, upload-time = "2025-12-17T23:31:40.432Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/0213f00ca3cf6fe1c9fdbd7fd467e801052fc85534f30c0e4684bd474190/time_machine-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:3bf0f428487f93b8fe9d27aa01eccc817885da3290b467341b4a4a795e1d1891", size = 18313, upload-time = "2025-12-17T23:31:41.617Z" }, + { url = "https://files.pythonhosted.org/packages/77/e4/811f96aa7a634b2b264d9a476f3400e710744dda503b4ad87a5c76db32c9/time_machine-3.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:347f6be2129fcd35b1c94b9387fcb2cbe7949b1e649228c5f22949a811b78976", size = 17037, upload-time = "2025-12-17T23:31:42.924Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e1/03aae5fbaa53859f665094af696338fc7cae733d926a024af69982712350/time_machine-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c188a9dda9fcf975022f1b325b466651b96a4dfc223c523ed7ed8d979f9bf3e8", size = 19143, upload-time = "2025-12-17T23:31:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/75/8f/98cb17bebb52b22ff4ec26984dd44280f9c71353c3bae0640a470e6683e5/time_machine-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17245f1cc2dd13f9d63a174be59bb2684a9e5e0a112ab707e37be92068cd655f", size = 15273, upload-time = "2025-12-17T23:31:45.246Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2f/ca11e4a7897234bb9331fcc5f4ed4714481ba4012370cc79a0ae8c42ea0a/time_machine-3.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9bd1de1996e76efd36ae15970206c5089fb3728356794455bd5cd8d392b5537", size = 31049, upload-time = "2025-12-17T23:31:46.613Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ad/d17d83a59943094e6b6c6a3743caaf6811b12203c3e07a30cc7bcc2ab7ee/time_machine-3.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98493cd50e8b7f941eab69b9e18e697ad69db1a0ec1959f78f3d7b0387107e5c", size = 32632, upload-time = "2025-12-17T23:31:47.72Z" }, + { url = "https://files.pythonhosted.org/packages/71/50/d60576d047a0dfb5638cdfb335e9c3deb6e8528544fa0b3966a8480f72b7/time_machine-3.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31f2a33d595d9f91eb9bc7f157f0dc5721f5789f4c4a9e8b852cdedb2a7d9b16", size = 34289, upload-time = "2025-12-17T23:31:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/fa/fe/4afa602dbdebddde6d0ea4a7fe849e49b9bb85dc3fb415725a87ccb4b471/time_machine-3.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9f78ac4213c10fbc44283edd1a29cfb7d3382484f4361783ddc057292aaa1889", size = 33175, upload-time = "2025-12-17T23:31:50.611Z" }, + { url = "https://files.pythonhosted.org/packages/0d/87/c152e23977c1d7d7c94eb3ed3ea45cc55971796205125c6fdff40db2c60f/time_machine-3.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c1326b09e947b360926d529a96d1d9e126ce120359b63b506ecdc6ee20755c23", size = 31170, upload-time = "2025-12-17T23:31:51.645Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/54acf51d0f3ade3b51eab73df6192937c9a938753ef5456dff65eb8630be/time_machine-3.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9f2949f03d15264cc15c38918a2cda8966001f0f4ebe190cbfd9c56d91aed8ac", size = 32292, upload-time = "2025-12-17T23:31:52.803Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bc/3745963f36e75661a807196428639327a366f4332f35f1f775c074d4062f/time_machine-3.2.0-cp311-cp311-win32.whl", hash = "sha256:6dfe48e0499e6e16751476b9799e67be7514e6ef04cdf39571ef95a279645831", size = 17349, upload-time = "2025-12-17T23:31:54.19Z" }, + { url = "https://files.pythonhosted.org/packages/82/a2/057469232a99d1f5a0160ae7c5bae7b095c9168b333dd598fcbcfbc1c87b/time_machine-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:809bdf267a29189c304154873620fe0bcc0c9513295fa46b19e21658231c4915", size = 18191, upload-time = "2025-12-17T23:31:55.472Z" }, + { url = "https://files.pythonhosted.org/packages/79/d8/bf9c8de57262ee7130d92a6ed49ed6a6e40a36317e46979428d373630c12/time_machine-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:a3f4c17fa90f54902a3f8692c75caf67be87edc3429eeb71cb4595da58198f8e", size = 16905, upload-time = "2025-12-17T23:31:56.658Z" }, + { url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" }, + { url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" }, + { url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" }, + { url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/2ca9e7af3df540dc1c79e3de588adeddb7dcc2107829248e6969c4f14167/time_machine-3.2.0-cp312-cp312-win32.whl", hash = "sha256:3f74623648b936fdce5f911caf386c0a0b579456410975de8c0dfeaaffece1d8", size = 17371, upload-time = "2025-12-17T23:32:07.164Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ce/21d23efc9c2151939af1b7ee4e60d86d661b74ef32b8eaa148f6fe8c899c/time_machine-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:34e26a41d994b5e4b205136a90e9578470386749cc9a2ecf51ca18f83ce25e23", size = 18132, upload-time = "2025-12-17T23:32:08.447Z" }, + { url = "https://files.pythonhosted.org/packages/2f/34/c2b70be483accf6db9e5d6c3139bce3c38fe51f898ccf64e8d3fe14fbf4d/time_machine-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:0615d3d82c418d6293f271c348945c5091a71f37e37173653d5c26d0e74b13a8", size = 16930, upload-time = "2025-12-17T23:32:09.477Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/bf/28/f922022269749cb02eee2b62919671153c4088994fa955a6b0e50327ff81/time_machine-3.2.0-cp313-cp313-win32.whl", hash = "sha256:a66fe55a107e46916007a391d4030479df8864ec6ad6f6a6528221befc5c886e", size = 17397, upload-time = "2025-12-17T23:32:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/fd87cde397f4a7bea493152f0aca8fd569ec709cad9e0f2ca7011eb8c7f7/time_machine-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:30c9ce57165df913e4f74e285a8ab829ff9b7aa3e5ec0973f88f642b9a7b3d15", size = 18139, upload-time = "2025-12-17T23:32:20.991Z" }, + { url = "https://files.pythonhosted.org/packages/75/81/b8ce58233addc5d7d54d2fabc49dcbc02d79e3f079d150aa1bec3d5275ef/time_machine-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:89cad7e179e9bdcc84dcf09efe52af232c4cc7a01b3de868356bbd59d95bd9b8", size = 16964, upload-time = "2025-12-17T23:32:22.075Z" }, + { url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" }, + { url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" }, + { url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/85/c8/c14265212436da8e0814c45463987b3f57de3eca4de023cc2eabb0c62ef3/time_machine-3.2.0-cp313-cp313t-win32.whl", hash = "sha256:3498719f8dab51da76d29a20c1b5e52ee7db083dddf3056af7fa69c1b94e1fe6", size = 17852, upload-time = "2025-12-17T23:32:32.079Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bc/8acb13cf6149f47508097b158a9a8bec9ec4530a70cb406124e8023581f5/time_machine-3.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e0d90bee170b219e1d15e6a58164aa808f5170090e4f090bd0670303e34181b1", size = 18918, upload-time = "2025-12-17T23:32:33.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/c443ee508c2708fd2514ccce9052f5e48888783ce690506919629ebc8eb0/time_machine-3.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:051de220fdb6e20d648111bbad423d9506fdbb2e44d4429cef3dc0382abf1fc2", size = 17261, upload-time = "2025-12-17T23:32:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" }, + { url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" }, + { url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/aa/235357da4f69a51a8d35fcbfcfa77cdc7dc24f62ae54025006570bda7e2d/time_machine-3.2.0-cp314-cp314-win32.whl", hash = "sha256:14d6778273c543441863dff712cd1d7803dee946b18de35921eb8df10714539d", size = 17544, upload-time = "2025-12-17T23:32:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/6c8405a7276be79693b792cff22ce41067ec05db26a7d02f2d5b06324434/time_machine-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbf821da96dbc80d349fa9e7c36e670b41d68a878d28c8850057992fed430eef", size = 18423, upload-time = "2025-12-17T23:32:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/d9/03/a3cf419e20c35fc203c6e4fed48b5b667c1a2b4da456d9971e605f73ecef/time_machine-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:71c75d71f8e68abc8b669bca26ed2ddd558430a6c171e32b8620288565f18c0e", size = 17050, upload-time = "2025-12-17T23:32:48.91Z" }, + { url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" }, + { url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" }, + { url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/5b/9e/5f4b2ea63b267bd78f3245e76f5528836611b5f2d30b5e7300a722fe4428/time_machine-3.2.0-cp314-cp314t-win32.whl", hash = "sha256:75724762ffd517e7e80aaec1fad1ff5a7414bd84e2b3ee7a0bacfeb67c14926e", size = 18091, upload-time = "2025-12-17T23:32:59.403Z" }, + { url = "https://files.pythonhosted.org/packages/39/6f/456b1f4d2700ae02b19eba830f870596a4b89b74bac3b6c80666f1b108c5/time_machine-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2526abbd053c5bca898d1b3e7898eec34626b12206718d8c7ce88fd12c1c9c5c", size = 19208, upload-time = "2025-12-17T23:33:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2f/22/8063101427ecd3d2652aada4d21d0876b07a3dc789125bca2ee858fec3ed/time_machine-3.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7f2fb6784b414edbe2c0b558bfaab0c251955ba27edd62946cce4a01675a992c", size = 17359, upload-time = "2025-12-17T23:33:01.54Z" }, +] + [[package]] name = "toml" version = "0.10.2"