Skip to content

Commit

Permalink
NEW: implement Django backend ✨
Browse files Browse the repository at this point in the history
  • Loading branch information
eigenein committed Jul 12, 2023
1 parent 83ec498 commit 383a799
Show file tree
Hide file tree
Showing 24 changed files with 517 additions and 102 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ jobs:
- name: "🧪 Tests"
if: success() || failure()
run: poetry run pytest tests --test-redis
env:
DJANGO_SETTINGS_MODULE: "tests.backends.django.settings"

- name: "📤 Codecov"
if: success() || failure()
Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
SRC:=cachetory tests
SRC := cachetory tests
DJANGO_SETTINGS_MODULE := tests.backends.django.settings

.PHONY: all
all: install lint test build
Expand All @@ -8,6 +9,7 @@ clean:
find . -name "*.pyc" -delete
rm -rf *.egg-info build
rm -rf coverage*.xml .coverage
poetry env remove --all

.PHONY: install
install:
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ Note the following **caveats**:
Dummy backend that always succeeds but never stores anything. Any values get forgotten immediately,
and operations behave as if the cache always is empty.

### Django

![scheme: django](https://img.shields.io/badge/scheme-django://-important)

| Sync | Async |
|:----------------------------------------|:------------------------------------------|
| `cachetory.backends.sync.DjangoBackend` | `cachetory.backends.async_.DjangoBackend` |

Adapter for the Django cache framework: allows using a pre-configured Django cache for Cachetory's `Cache`.

## Supported serializers

### Pickle
Expand Down
16 changes: 11 additions & 5 deletions cachetory/backends/async_/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from .memory import MemoryBackend

try:
# noinspection PyUnresolvedReferences
from .redis import RedisBackend
except ImportError:
_is_redis_available = False
else:
_is_redis_available = True
RedisBackend = None # type: ignore[assignment, misc]

try:
from .django import DjangoBackend
except ImportError:
DjangoBackend = None # type: ignore[assignment, misc]


def from_url(url: str) -> AsyncBackend:
Expand All @@ -26,9 +28,13 @@ def from_url(url: str) -> AsyncBackend:
if scheme == "memory":
return MemoryBackend.from_url(url)
if scheme in ("redis", "rediss", "redis+unix"):
if not _is_redis_available:
if RedisBackend is None:
raise ValueError(f"`{scheme}://` requires `cachetory[redis]` extra") # pragma: no cover
return RedisBackend.from_url(url)
if scheme == "dummy":
return DummyBackend.from_url(url)
if scheme == "django":
if DjangoBackend is None:
raise ValueError(f"`{scheme}://` requires `cachetory[django]` extra") # pragma: no cover
return DjangoBackend.from_url(url)
raise ValueError(f"`{scheme}://` is not supported")
70 changes: 70 additions & 0 deletions cachetory/backends/async_/django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

from datetime import datetime, timedelta
from typing import AsyncIterable, Generic, Iterable
from urllib.parse import urlparse

from django.core.cache import BaseCache, caches # type: ignore[import]
from django.core.cache.backends.base import DEFAULT_TIMEOUT # type: ignore[import]

from cachetory.interfaces.backends.async_ import AsyncBackend
from cachetory.interfaces.backends.private import WireT
from cachetory.private.datetime import make_time_to_live

_SENTINEL = object()


class DjangoBackend(AsyncBackend[WireT], Generic[WireT]):
"""Django cache adapter."""

__slots__ = ("_cache",)

@classmethod
def from_url(cls, url: str) -> DjangoBackend[WireT]:
return DjangoBackend(caches[urlparse(url).hostname])

def __init__(self, cache: BaseCache) -> None:
self._cache = cache

async def get(self, key: str) -> WireT:
if (value := await self._cache.aget(key, _SENTINEL)) is not _SENTINEL:
return value
raise KeyError(key)

async def get_many(self, *keys: str) -> AsyncIterable[tuple[str, WireT]]:
for item in (await self._cache.aget_many(keys)).items():
yield item

async def set( # noqa: A003
self,
key: str,
value: WireT,
*,
time_to_live: timedelta | None = None,
if_not_exists: bool = False,
) -> bool:
timeout = self._to_timeout(time_to_live)
if if_not_exists:
await self._cache.aget_or_set(key, value, timeout)
else:
await self._cache.aset(key, value, timeout)
return True

async def set_many(self, items: Iterable[tuple[str, WireT]]) -> None:
await self._cache.aset_many(dict(items))

async def delete(self, key: str) -> bool:
return await self._cache.adelete(key)

async def clear(self) -> None:
await self._cache.aclear()

async def expire_in(self, key: str, time_to_live: timedelta | None = None) -> None:
await self._cache.atouch(key, self._to_timeout(time_to_live))

async def expire_at(self, key: str, deadline: datetime | None) -> None:
await self.expire_in(key, make_time_to_live(deadline))

@staticmethod
def _to_timeout(time_to_live: timedelta | None) -> float:
return time_to_live.total_seconds() if time_to_live is not None else DEFAULT_TIMEOUT
2 changes: 1 addition & 1 deletion cachetory/backends/async_/dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class DummyBackend(AsyncBackend[WireT], Generic[WireT]):
"""Dummy backend that stores nothing."""

@classmethod
def from_url(cls, url: str) -> DummyBackend:
def from_url(cls, url: str) -> DummyBackend[WireT]:
return DummyBackend()

async def get(self, key: str) -> WireT: # pragma: no cover
Expand Down
2 changes: 1 addition & 1 deletion cachetory/backends/async_/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class MemoryBackend(AsyncBackend[WireT], Generic[WireT]):
__slots__ = ("_inner",)

@classmethod
def from_url(cls, _url: str) -> MemoryBackend:
def from_url(cls, _url: str) -> MemoryBackend[WireT]:
return MemoryBackend()

def __init__(self) -> None:
Expand Down
16 changes: 11 additions & 5 deletions cachetory/backends/sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from .memory import MemoryBackend

try:
# noinspection PyUnresolvedReferences
from .redis import RedisBackend
except ImportError:
_is_redis_available = False
else:
_is_redis_available = True
RedisBackend = None # type: ignore[assignment, misc]

try:
from .django import DjangoBackend
except ImportError:
DjangoBackend = None # type: ignore[assignment, misc]


def from_url(url: str) -> SyncBackend:
Expand All @@ -26,9 +28,13 @@ def from_url(url: str) -> SyncBackend:
if scheme == "memory":
return MemoryBackend.from_url(url)
if scheme in ("redis", "rediss", "redis+unix"):
if not _is_redis_available:
if RedisBackend is None:
raise ValueError(f"`{scheme}://` requires `cachetory[redis]` extra") # pragma: no cover
return RedisBackend.from_url(url)
if scheme == "dummy":
return DummyBackend.from_url(url)
if scheme == "django":
if DjangoBackend is None:
raise ValueError(f"`{scheme}://` requires `cachetory[django]` extra") # pragma: no cover
return DjangoBackend.from_url(url)
raise ValueError(f"`{scheme}://` is not supported")
69 changes: 69 additions & 0 deletions cachetory/backends/sync/django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

from datetime import datetime, timedelta
from typing import Generic, Iterable
from urllib.parse import urlparse

from django.core.cache import BaseCache, caches # type: ignore[import]
from django.core.cache.backends.base import DEFAULT_TIMEOUT # type: ignore[import]

from cachetory.interfaces.backends.private import WireT
from cachetory.interfaces.backends.sync import SyncBackend
from cachetory.private.datetime import make_time_to_live

_SENTINEL = object()


class DjangoBackend(SyncBackend[WireT], Generic[WireT]):
"""Django cache adapter."""

__slots__ = ("_cache",)

@classmethod
def from_url(cls, url: str) -> DjangoBackend[WireT]:
return DjangoBackend(caches[urlparse(url).hostname])

def __init__(self, cache: BaseCache) -> None:
self._cache = cache

def get(self, key: str) -> WireT:
if (value := self._cache.get(key, _SENTINEL)) is not _SENTINEL:
return value
raise KeyError(key)

def get_many(self, *keys: str) -> Iterable[tuple[str, WireT]]:
return self._cache.get_many(keys).items()

def set( # noqa: A003
self,
key: str,
value: WireT,
*,
time_to_live: timedelta | None = None,
if_not_exists: bool = False,
) -> bool:
timeout = self._to_timeout(time_to_live)
if if_not_exists:
self._cache.get_or_set(key, value, timeout)
else:
self._cache.set(key, value, timeout)
return True

def set_many(self, items: Iterable[tuple[str, WireT]]) -> None:
self._cache.set_many(dict(items))

def delete(self, key: str) -> bool:
return self._cache.delete(key)

def clear(self) -> None:
self._cache.clear()

def expire_in(self, key: str, time_to_live: timedelta | None = None) -> None:
self._cache.touch(key, self._to_timeout(time_to_live))

def expire_at(self, key: str, deadline: datetime | None) -> None:
self.expire_in(key, make_time_to_live(deadline))

@staticmethod
def _to_timeout(time_to_live: timedelta | None) -> float:
return time_to_live.total_seconds() if time_to_live is not None else DEFAULT_TIMEOUT
2 changes: 1 addition & 1 deletion cachetory/backends/sync/dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class DummyBackend(SyncBackend[WireT], Generic[WireT]):
"""Dummy backend that stores nothing."""

@classmethod
def from_url(cls, url: str) -> DummyBackend:
def from_url(cls, url: str) -> DummyBackend[WireT]:
return DummyBackend()

def get(self, key: str) -> WireT: # pragma: no cover
Expand Down
2 changes: 1 addition & 1 deletion cachetory/backends/sync/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class MemoryBackend(SyncBackend[WireT], Generic[WireT]):
__slots__ = ("_entries",)

@classmethod
def from_url(cls, _url: str) -> MemoryBackend:
def from_url(cls, _url: str) -> MemoryBackend[WireT]:
return MemoryBackend()

def __init__(self) -> None:
Expand Down
2 changes: 2 additions & 0 deletions cachetory/backends/sync/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
class RedisBackend(SyncBackend[bytes]):
"""Synchronous Redis backend."""

__slots__ = ("_client",)

@classmethod
def from_url(cls, url: str) -> RedisBackend:
if url.startswith("redis+"):
Expand Down
13 changes: 11 additions & 2 deletions cachetory/interfaces/backends/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
from __future__ import annotations

from abc import ABCMeta, abstractmethod
from contextlib import AbstractAsyncContextManager
from contextlib import AbstractAsyncContextManager, AbstractContextManager
from datetime import datetime, timedelta, timezone
from typing import AsyncIterable, Generic, Iterable

from typing_extensions import Protocol
from typing_extensions import Never, Protocol

from cachetory.interfaces.backends.private import WireT, WireT_co, WireT_contra

Expand Down Expand Up @@ -75,6 +75,8 @@ async def set( # noqa: A003
`True` if the value has been successfully set, `False` when `if_not_exists` is true
and the key is already existing.
"""

# TODO: just return `None`.
raise NotImplementedError

async def set_many(self, items: Iterable[tuple[str, WireT_contra]]) -> None:
Expand All @@ -97,6 +99,7 @@ async def clear(self) -> None: # pragma: no cover


class AsyncBackend(
AbstractContextManager,
AbstractAsyncContextManager,
AsyncBackendRead[WireT],
AsyncBackendWrite[WireT],
Expand All @@ -116,5 +119,11 @@ def from_url(cls, url: str) -> AsyncBackend: # pragma: no cover
"""
raise NotImplementedError

def __enter__(self) -> Never:
raise RuntimeError("use async context manager protocol instead")

def __exit__(self, _exc_type, _exc_val, _exc_tb) -> Never:
raise RuntimeError("use async context manager protocol instead")

async def __aexit__(self, exc_type, exc_value, traceback) -> None:
return None
2 changes: 2 additions & 0 deletions cachetory/interfaces/backends/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ def set( # noqa: A003
`True` if the value has been successfully set, `False` when `if_not_exists` is true
and the key is already existing.
"""

# TODO: just return `None`.
raise NotImplementedError

def set_many(self, items: Iterable[tuple[str, WireT_contra]]) -> None:
Expand Down
13 changes: 11 additions & 2 deletions cachetory/private/datetime.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from typing import Optional

ZERO_TIMEDELTA = timedelta()


def make_deadline(time_to_live: Optional[timedelta] = None) -> Optional[datetime]:
def make_deadline(time_to_live: timedelta | None = None) -> datetime | None:
return datetime.now(timezone.utc) + time_to_live if time_to_live is not None else None


def make_time_to_live(deadline: datetime | None = None) -> timedelta | None:
if deadline is None:
return None
return max(deadline - datetime.now(timezone.utc), ZERO_TIMEDELTA)
Loading

0 comments on commit 383a799

Please sign in to comment.