diff --git a/.secrets.baseline b/.secrets.baseline index 809c294..97c76f0 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -231,7 +231,7 @@ "filename": "src/cachekit/config/decorator.py", "hashed_secret": "1a9a9d37d8305b0cd8353468065cf844259e1b1f", "is_verified": false, - "line_number": 567 + "line_number": 508 } ], "src/cachekit/serializers/interop_serializer.py": [ @@ -887,5 +887,5 @@ } ] }, - "generated_at": "2026-08-07T16:45:43Z" + "generated_at": "2026-09-02T03:10:40Z" } diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index dd3cee5..0d3a6e4 100644 --- a/src/cachekit/config/decorator.py +++ b/src/cachekit/config/decorator.py @@ -27,9 +27,6 @@ # Backend Resolution Layer -# Sentinel for unset explicit backend parameter -_UNSET = object() - # Module-level default backend (set via set_default_backend()) _default_backend: BaseBackend | None = None @@ -80,62 +77,6 @@ def get_default_backend() -> BaseBackend | None: return _default_backend -def _resolve_backend(explicit_backend: object = _UNSET) -> BaseBackend | None: - """Resolve backend via three-tier lookup. - - Priority order: - 1. Explicit backend= kwarg (highest priority) - 2. Module-level default (set_default_backend) - 3. REDIS_URL environment variable (auto-create RedisBackend) - - Zero-config UX: If REDIS_URL is set, backend is auto-created. - Fail-fast: If no backend configured, raise helpful ConfigurationError. - - Args: - explicit_backend: Explicit backend from decorator kwarg (use UNSET sentinel for not provided) - - Returns: - Resolved backend instance or None for explicit L1-only mode - - Raises: - ConfigurationError: If no backend configured and REDIS_URL not set - """ - # Tier 1: Explicit backend parameter (highest priority) - if explicit_backend is not _UNSET: - return explicit_backend # type: ignore[return-value] - - # Tier 2: Module-level default - if _default_backend is not None: - return _default_backend - - # Tier 3: Auto-create from env var (CACHEKIT_REDIS_URL > REDIS_URL) - # Check if either env var is set as signal to create Redis backend - # Actual URL resolution handled by RedisBackendConfig via AliasChoices - if os.environ.get("CACHEKIT_REDIS_URL") or os.environ.get("REDIS_URL"): - # Lazy import to avoid circular dependency - from cachekit.backends.redis import RedisBackend - - # RedisBackend() resolves its own client provider: a DI-registered - # CacheClientProvider when present, else a per-instance pool from env - # config. The eager container.get() this used to do crashed the - # zero-config path — nothing registers the provider by default (#222). - return RedisBackend() - - # No backend configured - fail fast with helpful message - raise ConfigurationError( - "No backend configured.\n\n" - "Quick fix (90% of cases):\n" - " export REDIS_URL=redis://localhost:6379\n\n" - "Or explicitly configure:\n" - " from cachekit import set_default_backend\n" - " from cachekit.backends import RedisBackend\n" - " set_default_backend(RedisBackend('redis://localhost:6379'))\n\n" - "Or use L1-only mode (no Redis):\n" - " @cache(backend=None) # In-memory cache only\n\n" - "See: https://github.com/cachekit-io/cachekit-py/blob/main/docs/guides/backend-guide.md" - ) - - @dataclass(frozen=True) class DecoratorConfig: """Unified immutable configuration for cache decorator. diff --git a/tests/unit/config/test_backend_resolution.py b/tests/unit/config/test_backend_resolution.py deleted file mode 100644 index 1cc5b00..0000000 --- a/tests/unit/config/test_backend_resolution.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Unit tests for backend resolution logic. - -Tests 3-tier resolution: -- Tier 1: Explicit backend kwarg (highest priority) -- Tier 2: Module default via set_default_backend() -- Tier 3: REDIS_URL env var auto-creates RedisBackend -- Test fail-fast with helpful error when no backend configured -- Test set_default_backend() / get_default_backend() module API -""" - -from __future__ import annotations - -import os -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -from cachekit.config.decorator import ( - _resolve_backend, - get_default_backend, - set_default_backend, -) -from cachekit.config.validation import ConfigurationError - -if TYPE_CHECKING: - from cachekit.backends.base import BaseBackend - - -@pytest.mark.unit -class TestSetDefaultBackend: - """Test set_default_backend() module API.""" - - def teardown_method(self) -> None: - """Clear default backend after each test.""" - set_default_backend(None) - - def test_set_default_backend(self) -> None: - """Test setting default backend.""" - mock_backend: BaseBackend = MagicMock() - set_default_backend(mock_backend) - assert get_default_backend() is mock_backend - - def test_set_default_backend_to_none(self) -> None: - """Test clearing default backend.""" - mock_backend: BaseBackend = MagicMock() - set_default_backend(mock_backend) - assert get_default_backend() is mock_backend - - set_default_backend(None) - assert get_default_backend() is None - - def test_get_default_backend_unset(self) -> None: - """Test get_default_backend() returns None when unset.""" - set_default_backend(None) - assert get_default_backend() is None - - -@pytest.mark.unit -class TestResolveBackendTier1: - """Test Tier 1: Explicit backend parameter (highest priority).""" - - def teardown_method(self) -> None: - """Clear default backend after each test.""" - set_default_backend(None) - - def test_explicit_backend_overrides_default(self) -> None: - """Test explicit backend parameter overrides module default.""" - default_backend: BaseBackend = MagicMock() - explicit_backend: BaseBackend = MagicMock() - - set_default_backend(default_backend) - resolved = _resolve_backend(explicit_backend) - - assert resolved is explicit_backend - - def test_explicit_backend_none_for_l1_only(self) -> None: - """Test explicit backend=None enables L1-only mode.""" - default_backend: BaseBackend = MagicMock() - set_default_backend(default_backend) - - resolved = _resolve_backend(None) - assert resolved is None - - @patch.dict(os.environ, {"REDIS_URL": "redis://localhost:6379"}) - def test_explicit_backend_overrides_env_var(self) -> None: - """Test explicit backend overrides REDIS_URL env var.""" - explicit_backend: BaseBackend = MagicMock() - resolved = _resolve_backend(explicit_backend) - assert resolved is explicit_backend - - -@pytest.mark.unit -class TestResolveBackendTier2: - """Test Tier 2: Module-level default (set_default_backend).""" - - def teardown_method(self) -> None: - """Clear default backend after each test.""" - set_default_backend(None) - - @patch.dict(os.environ, {}, clear=True) - def test_module_default_used_when_no_explicit_backend(self) -> None: - """Test module default used when no explicit backend.""" - # Clear REDIS_URL env var - os.environ.pop("REDIS_URL", None) - - default_backend: BaseBackend = MagicMock() - set_default_backend(default_backend) - - resolved = _resolve_backend() - assert resolved is default_backend - - @patch.dict(os.environ, {"REDIS_URL": "redis://localhost:6379"}) - def test_module_default_overrides_env_var(self) -> None: - """Test module default has higher priority than REDIS_URL env var.""" - default_backend: BaseBackend = MagicMock() - set_default_backend(default_backend) - - resolved = _resolve_backend() - assert resolved is default_backend - - -@pytest.mark.unit -class TestResolveBackendTier3: - """Test Tier 3: REDIS_URL env var auto-creates RedisBackend.""" - - def teardown_method(self) -> None: - """Clear default backend after each test.""" - set_default_backend(None) - - @patch.dict(os.environ, {"REDIS_URL": "redis://localhost:6379"}) - def test_redis_url_auto_creates_backend(self) -> None: - """Test REDIS_URL env var auto-creates RedisBackend.""" - from cachekit.backends.redis import RedisBackend - - with patch("cachekit.di.DIContainer.get", return_value=MagicMock()): - resolved = _resolve_backend() - - assert isinstance(resolved, RedisBackend) - - @patch.dict(os.environ, {"REDIS_URL": "redis://custom-host:6380/2"}) - def test_redis_url_custom_url(self) -> None: - """Test REDIS_URL with custom host/port/db.""" - from cachekit.backends.redis import RedisBackend - - with patch("cachekit.di.DIContainer.get", return_value=MagicMock()): - resolved = _resolve_backend() - - assert isinstance(resolved, RedisBackend) - - -@pytest.mark.unit -class TestResolveBackendFailFast: - """Test fail-fast behavior when no backend configured.""" - - def teardown_method(self) -> None: - """Clear default backend after each test.""" - set_default_backend(None) - - @patch.dict(os.environ, {}, clear=True) - def test_no_backend_raises_configuration_error(self) -> None: - """Test ConfigurationError raised when no backend configured.""" - # Clear REDIS_URL env var - os.environ.pop("REDIS_URL", None) - - with pytest.raises(ConfigurationError, match="No backend configured"): - _resolve_backend() - - @patch.dict(os.environ, {}, clear=True) - def test_error_message_includes_redis_url_fix(self) -> None: - """Test error message includes REDIS_URL quick fix.""" - # Clear REDIS_URL env var - os.environ.pop("REDIS_URL", None) - - with pytest.raises(ConfigurationError, match="export REDIS_URL=redis://localhost:6379"): - _resolve_backend() - - @patch.dict(os.environ, {}, clear=True) - def test_error_message_includes_set_default_backend_fix(self) -> None: - """Test error message includes set_default_backend() fix.""" - # Clear REDIS_URL env var - os.environ.pop("REDIS_URL", None) - - with pytest.raises(ConfigurationError, match="set_default_backend"): - _resolve_backend() - - @patch.dict(os.environ, {}, clear=True) - def test_error_message_includes_l1_only_fix(self) -> None: - """Test error message includes L1-only mode fix.""" - # Clear REDIS_URL env var - os.environ.pop("REDIS_URL", None) - - with pytest.raises(ConfigurationError, match="@cache\\(backend=None\\)"): - _resolve_backend() - - -@pytest.mark.unit -class TestResolveBackendPriority: - """Test resolution priority across all 3 tiers.""" - - def teardown_method(self) -> None: - """Clear default backend after each test.""" - set_default_backend(None) - - @patch.dict(os.environ, {"REDIS_URL": "redis://localhost:6379"}) - def test_tier1_beats_tier2_beats_tier3(self) -> None: - """Test explicit backend > module default > REDIS_URL.""" - # Setup all 3 tiers - explicit_backend: BaseBackend = MagicMock(name="explicit") - default_backend: BaseBackend = MagicMock(name="default") - # REDIS_URL set in environment - - set_default_backend(default_backend) - - # Tier 1 (explicit) wins - resolved = _resolve_backend(explicit_backend) - assert resolved is explicit_backend - - @patch.dict(os.environ, {"REDIS_URL": "redis://localhost:6379"}) - def test_tier2_beats_tier3(self) -> None: - """Test module default > REDIS_URL.""" - default_backend: BaseBackend = MagicMock() - set_default_backend(default_backend) - - # Tier 2 (module default) wins - resolved = _resolve_backend() - assert resolved is default_backend - - @patch.dict(os.environ, {"REDIS_URL": "redis://localhost:6379"}) - def test_tier3_only_when_no_tier1_or_tier2(self) -> None: - """Test REDIS_URL only used when no explicit or module default.""" - from cachekit.backends.redis import RedisBackend - - with patch("cachekit.di.DIContainer.get", return_value=MagicMock()): - resolved = _resolve_backend() - - assert isinstance(resolved, RedisBackend) - - -@pytest.mark.unit -class TestResolveBackendUnsetSentinel: - """Test _UNSET sentinel for explicit backend parameter.""" - - def test_unset_sentinel_default_value(self) -> None: - """Test _UNSET is the default value for explicit_backend.""" - # This test verifies the sentinel pattern works correctly - default_backend: BaseBackend = MagicMock() - set_default_backend(default_backend) - - # No explicit backend parameter (uses _UNSET default) - resolved = _resolve_backend() - assert resolved is default_backend - - # Cleanup - set_default_backend(None) - - def test_unset_vs_none(self) -> None: - """Test _UNSET (not provided) vs None (L1-only mode).""" - default_backend: BaseBackend = MagicMock() - set_default_backend(default_backend) - - # _UNSET means "not provided" -> use module default - resolved_unset = _resolve_backend() - assert resolved_unset is default_backend - - # None means "explicitly L1-only mode" - resolved_none = _resolve_backend(None) - assert resolved_none is None - - # Cleanup - set_default_backend(None)