diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml index 2a52aa8..774655a 100644 --- a/.github/workflows/lint_and_test.yml +++ b/.github/workflows/lint_and_test.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13.0-rc.3' ] + python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] steps: - uses: actions/checkout@v2 @@ -30,7 +30,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13.0-rc.3' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] services: redis: image: redis:6 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5cb1840..edd6359 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,6 +31,5 @@ repos: additional_dependencies: - pytest-asyncio - redis - - types-redis - itsdangerous - fastapi diff --git a/README.md b/README.md index d21da7a..dc85a56 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +from sqlite3 import connect + ## Starsessions Advanced sessions for Starlette and FastAPI frameworks @@ -11,12 +13,10 @@ Advanced sessions for Starlette and FastAPI frameworks ## Installation -Install `starsessions` using PIP or poetry: +Install `starsessions` package: ```bash pip install starsessions -# or -poetry add starsessions ``` Use `redis` extra for [Redis support](#redis). @@ -100,7 +100,8 @@ You can automatically load a session by using `SessionAutoloadMiddleware` middle ### Session autoload -For performance reasons, the session is not autoloaded by default. Sometimes it is annoying to call `load_session` too often. +For performance reasons, the session is not autoloaded by default. Sometimes it is annoying to call `load_session` too +often. We provide `SessionAutoloadMiddleware` class to reduce the boilerplate code by autoloading the session for you. There are two options: always autoload or autoload for specific paths only. @@ -148,7 +149,8 @@ When rolling sessions are activated, the cookie expiration time will be extended Let's see how it works for example. First, on the first response you create a new session with `lifetime=3600`, then the user does another request, and the session gets extended by another 3600 seconds, and so on. This approach is useful when you want to use short-timed sessions but don't want them to interrupt in the middle of -the user's operation. With the rolling strategy, a session cookie will expire only after some period of the user's inactivity. +the user's operation. With the rolling strategy, a session cookie will expire only after some period of the user's +inactivity. To enable the rolling strategy set `rolling=True`. @@ -166,7 +168,8 @@ inactivity, but will be automatically extended by another 5 minutes while the us ### Cookie path -You can pass `cookie_path` argument to bind the session cookies to specific URLs. For example, to activate a session cookie +You can pass `cookie_path` argument to bind the session cookies to specific URLs. For example, to activate a session +cookie only for the admin area, use `cookie_path="/admin"` middleware argument. ```python @@ -232,18 +235,25 @@ Class: `starsessions.stores.redis.RedisStore` Stores session data in a Redis server. The store accepts either a connection URL or an instance of `Redis`. > Requires [redis-py](https://github.com/redis/redis-py), -> use `pip install starsessions[redis]` or `poetry add starsessions[redis]` +> use `pip install starsessions[redis]` + +> Note, redis-py requires explicit disconnect of connection. The library does not handle it for you at the moment. +> The recommended solution is to pass a Redis instance to the store and call `.close()` on application shutdown. +> For example, you can close the connection using lifespan handler. +> See more https://redis-py.readthedocs.io/en/latest/examples/asyncio_examples.html ```python -from redis.asyncio.utils import from_url +from redis.asyncio import Redis from starsessions.stores.redis import RedisStore -store = RedisStore('redis://localhost') -# or -redis = from_url('redis://localhost') +client = Redis.from_url('redis://localhost') +store = RedisStore(connection=client) + +store = RedisStore(connection=client) -store = RedisStore(connection=redis) +# close connection on shutdown +await client.close() ``` #### Redis key prefix @@ -326,8 +336,9 @@ The difference is that `lifetime` is the total session duration (set by the midd and `ttl` is the remaining session time. After `ttl` seconds the data can be safely deleted from the storage. > Your custom backend has to correctly handle cases when `lifetime = 0`. -In such cases, you don't have an exact expiration value, and you would have to find a way to extend session TTL on the storage -side, if any. +> In such cases, you don't have an exact expiration value, and you would have to find a way to extend session TTL on the +> storage +> side, if any. ## Serializers @@ -359,7 +370,8 @@ middleware = [ ## Session termination -The middleware will remove session data and cookies if the session has no data. Use `request.session.clear` to empty data. +The middleware will remove session data and cookies if the session has no data. Use `request.session.clear` to empty +data. ## Regenerating session ID diff --git a/examples/expiring.py b/examples/expiring.py index db46588..1c5ece2 100644 --- a/examples/expiring.py +++ b/examples/expiring.py @@ -9,6 +9,7 @@ import datetime import json + from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.requests import Request diff --git a/examples/fastapi_app.py b/examples/fastapi_app.py index e536dd0..c29609c 100644 --- a/examples/fastapi_app.py +++ b/examples/fastapi_app.py @@ -13,6 +13,7 @@ import datetime import typing + from fastapi import FastAPI from fastapi.requests import Request from fastapi.responses import JSONResponse, RedirectResponse diff --git a/examples/login.py b/examples/login.py index 41761ca..f91c093 100644 --- a/examples/login.py +++ b/examples/login.py @@ -51,12 +51,12 @@ async def profile(request: Request) -> Response: return RedirectResponse("/", 302) return HTMLResponse( - """ + f"""

Hi, {username}!

- """.format(username=username) + """ ) diff --git a/examples/redis_.py b/examples/redis_.py index 4091923..017a35e 100644 --- a/examples/redis_.py +++ b/examples/redis_.py @@ -12,9 +12,13 @@ Open http://localhost:8000 for management panel. """ +import contextlib import datetime import json import os +import typing + +from redis.asyncio import Redis from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.requests import Request @@ -53,13 +57,22 @@ async def clean(request: Request) -> RedirectResponse: return RedirectResponse("/") +redis_client = Redis.from_url(REDIS_URL) + + +@contextlib.asynccontextmanager +async def lifespan(app: Starlette) -> typing.AsyncGenerator[typing.Dict[str, typing.Any], None]: + async with redis_client: + yield {} + + routes = [ Route("/", endpoint=homepage), Route("/set", endpoint=set_time), Route("/clean", endpoint=clean), ] middleware = [ - Middleware(SessionMiddleware, store=RedisStore(REDIS_URL), lifetime=10, rolling=True), + Middleware(SessionMiddleware, store=RedisStore(connection=redis_client), lifetime=10, rolling=True), Middleware(SessionAutoloadMiddleware), ] -app = Starlette(debug=True, routes=routes, middleware=middleware) +app = Starlette(debug=True, routes=routes, middleware=middleware, lifespan=lifespan) diff --git a/examples/rolling.py b/examples/rolling.py index 28b10db..b3c4f9e 100644 --- a/examples/rolling.py +++ b/examples/rolling.py @@ -10,6 +10,7 @@ import datetime import json + from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.requests import Request diff --git a/examples/session_only.py b/examples/session_only.py index 2ce457f..787654d 100644 --- a/examples/session_only.py +++ b/examples/session_only.py @@ -17,6 +17,7 @@ import datetime import json + from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.requests import Request diff --git a/poetry.lock b/poetry.lock index 4e37adc..98a5226 100644 --- a/poetry.lock +++ b/poetry.lock @@ -16,13 +16,13 @@ typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} [[package]] name = "anyio" -version = "4.5.0" +version = "4.5.2" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.5.0-py3-none-any.whl", hash = "sha256:fdeb095b7cc5a5563175eedd926ec4ae55413bb4be5770c424af0ba46ccb4a78"}, - {file = "anyio-4.5.0.tar.gz", hash = "sha256:c5a275fe5ca0afd788001f58fca1e69e29ce706d746e317d660e21f70c530ef9"}, + {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, + {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, ] [package.dependencies] @@ -33,7 +33,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] trio = ["trio (>=0.26.1)"] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 8779294..88932cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ python = "^3.8.0" starlette = "*" itsdangerous = "^2" -redis = {version = ">=4.2.0rc1", optional = true} +redis = { version = ">=4.2.0rc1", optional = true } [tool.poetry.group.dev.dependencies] pytest = "^8.0" @@ -48,7 +48,7 @@ build-backend = "poetry.core.masonry.api" [tool.coverage.run] branch = true -source = ["starlette_dispatch"] +source = ["starsessions"] omit = ["tests/*", ".venv/*", ".git/*", "*/__main__.py", "examples"] [tool.coverage.report] diff --git a/starsessions/middleware.py b/starsessions/middleware.py index 809aef2..822a10c 100644 --- a/starsessions/middleware.py +++ b/starsessions/middleware.py @@ -1,6 +1,7 @@ import datetime import re import typing + from starlette.datastructures import MutableHeaders from starlette.requests import HTTPConnection from starlette.types import ASGIApp, Message, Receive, Scope, Send diff --git a/starsessions/serializers.py b/starsessions/serializers.py index bffbbc4..851bd94 100644 --- a/starsessions/serializers.py +++ b/starsessions/serializers.py @@ -6,11 +6,11 @@ class Serializer(abc.ABC): # pragma: no cover @abc.abstractmethod def serialize(self, data: typing.Any) -> bytes: - raise NotImplementedError() + raise NotImplementedError @abc.abstractmethod def deserialize(self, data: bytes) -> typing.Dict[str, typing.Any]: - raise NotImplementedError() + raise NotImplementedError class JsonSerializer(Serializer): diff --git a/starsessions/session.py b/starsessions/session.py index 10af70b..b5f4392 100644 --- a/starsessions/session.py +++ b/starsessions/session.py @@ -3,6 +3,7 @@ import secrets import time import typing + from starlette.requests import HTTPConnection from starsessions.exceptions import SessionNotLoaded diff --git a/starsessions/stores/base.py b/starsessions/stores/base.py index 864be59..34e75f1 100644 --- a/starsessions/stores/base.py +++ b/starsessions/stores/base.py @@ -13,7 +13,7 @@ async def read(self, session_id: str, lifetime: int) -> bytes: :param lifetime: session lifetime duration :returns bytes: session data as bytes """ - raise NotImplementedError() + raise NotImplementedError @abc.abstractmethod async def write(self, session_id: str, data: bytes, lifetime: int, ttl: int) -> str: @@ -30,7 +30,7 @@ async def write(self, session_id: str, data: bytes, lifetime: int, ttl: int) -> :param ttl: keep session data this amount of time, in seconds :returns str: session ID """ - raise NotImplementedError() + raise NotImplementedError @abc.abstractmethod async def remove(self, session_id: str) -> None: @@ -39,4 +39,4 @@ async def remove(self, session_id: str) -> None: :param session_id: ID associated with session """ - raise NotImplementedError() + raise NotImplementedError diff --git a/starsessions/stores/cookie.py b/starsessions/stores/cookie.py index 1a6ff08..c351a09 100644 --- a/starsessions/stores/cookie.py +++ b/starsessions/stores/cookie.py @@ -1,5 +1,6 @@ import typing from base64 import b64decode, b64encode + from itsdangerous import BadSignature, TimestampSigner from starlette.datastructures import Secret diff --git a/starsessions/stores/redis.py b/starsessions/stores/redis.py index 4d312a5..2d01abe 100644 --- a/starsessions/stores/redis.py +++ b/starsessions/stores/redis.py @@ -1,7 +1,8 @@ import functools import typing +import warnings + from redis.asyncio.client import Redis -from redis.asyncio.utils import from_url from starsessions.exceptions import ImproperlyConfigured from starsessions.stores.base import SessionStore @@ -11,19 +12,13 @@ def prefix_factory(prefix: str, key: str) -> str: return prefix + key -if typing.TYPE_CHECKING: # pragma: nocover - BaseRedis = Redis[bytes] -else: - BaseRedis = Redis - - class RedisStore(SessionStore): """Stores session data in a Redis server.""" def __init__( self, url: typing.Optional[str] = None, - connection: typing.Optional[BaseRedis] = None, + connection: typing.Optional[Redis] = None, prefix: typing.Union[typing.Callable[[str], str], str] = "starsessions.", gc_ttl: int = 3600 * 24 * 30, ) -> None: @@ -46,17 +41,20 @@ def __init__( self.gc_ttl = gc_ttl self.prefix: typing.Callable[[str], str] = prefix if connection: - self._connection: BaseRedis = connection + self._connection: Redis = connection else: assert url - self._connection = from_url(url) + warnings.warn( + "starsessions.stores.redis.RedisStore: 'url' argument is deprecated, use 'connection' instead.", + DeprecationWarning, + ) + self._connection = Redis.from_url(url) async def read(self, session_id: str, lifetime: int) -> bytes: - async with self._connection as client: - value = await client.get(self.prefix(session_id)) - if value is None: - return b"" - return value + value: bytes = await self._connection.get(self.prefix(session_id)) + if value is None: + return b"" + return value async def write(self, session_id: str, data: bytes, lifetime: int, ttl: int) -> str: if lifetime == 0: @@ -66,10 +64,8 @@ async def write(self, session_id: str, data: bytes, lifetime: int, ttl: int) -> ttl = self.gc_ttl ttl = max(1, ttl) - async with self._connection as client: - await client.set(self.prefix(session_id), data, ex=ttl) + await self._connection.set(self.prefix(session_id), data, ex=ttl) return session_id async def remove(self, session_id: str) -> None: - async with self._connection as client: - await client.delete(self.prefix(session_id)) + await self._connection.delete(self.prefix(session_id)) diff --git a/tests/backends/test_cookie.py b/tests/backends/test_cookie.py index 7bf36c6..6e3529e 100644 --- a/tests/backends/test_cookie.py +++ b/tests/backends/test_cookie.py @@ -3,7 +3,7 @@ from starsessions.stores import CookieStore, SessionStore -@pytest.fixture() +@pytest.fixture def cookie_store() -> SessionStore: return CookieStore("key") diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index 3ad68a7..afadc23 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -3,7 +3,7 @@ from starsessions.stores import InMemoryStore, SessionStore -@pytest.fixture() +@pytest.fixture def in_memory_store() -> SessionStore: return InMemoryStore() diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index c722cdf..6f911ff 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -2,65 +2,69 @@ import typing import pytest -import redis.asyncio +import redis.asyncio as redis from starsessions import ImproperlyConfigured -from starsessions.stores.base import SessionStore from starsessions.stores.redis import RedisStore +REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost") + def redis_key_callable(session_id: str) -> str: - return f"this:is:a:redis:key:{session_id}" + return f"prefix_{session_id}" -@pytest.fixture( - params=["prefix_", redis_key_callable], - ids=["using string", "using redis_key_callable"], -) -def redis_store(request: typing.Any) -> SessionStore: - redis_key = request.param +@pytest.mark.parametrize("prefix", ["prefix_", redis_key_callable]) +async def test_redis_prefix(prefix: typing.Union[str, typing.Callable[[str], str]]) -> None: url = os.environ.get("REDIS_URL", "redis://localhost") - return RedisStore(url, prefix=redis_key) + client = redis.Redis.from_url(url) + redis_store = RedisStore(prefix=prefix, connection=client) + async with client: + new_id = await redis_store.write("session_id", b"data", lifetime=60, ttl=60) + assert new_id == "session_id" + assert await redis_store.read("session_id", lifetime=60) == b"data" + assert await client.get("prefix_session_id") == b"data" -@pytest.mark.asyncio -async def test_redis_read_write(redis_store: SessionStore) -> None: - new_id = await redis_store.write("session_id", b"data", lifetime=60, ttl=60) - assert new_id == "session_id" - assert await redis_store.read("session_id", lifetime=60) == b"data" +async def test_redis_read_write() -> None: + client = redis.Redis.from_url(REDIS_URL) + redis_store = RedisStore(connection=client) + async with client: + new_id = await redis_store.write("session_id", b"data", lifetime=60, ttl=60) + assert new_id == "session_id" + assert await redis_store.read("session_id", lifetime=60) == b"data" -@pytest.mark.asyncio -async def test_redis_write_with_session_only_setup(redis_store: SessionStore) -> None: - await redis_store.write("session_id", b"data", lifetime=0, ttl=0) +async def test_redis_write_with_session_only_setup() -> None: + client = redis.Redis.from_url(REDIS_URL) + redis_store = RedisStore(connection=client) + async with client: + await redis_store.write("session_id", b"data", lifetime=0, ttl=0) -@pytest.mark.asyncio -async def test_redis_remove(redis_store: SessionStore) -> None: - await redis_store.write("session_id", b"data", lifetime=60, ttl=60) - await redis_store.remove("session_id") - assert await redis_store.read("session_id", lifetime=60) == b"" +async def test_redis_remove() -> None: + client = redis.Redis.from_url(REDIS_URL) + redis_store = RedisStore(connection=client) + async with client: + await redis_store.write("session_id", b"data", lifetime=60, ttl=60) + await redis_store.remove("session_id") + assert await redis_store.read("session_id", lifetime=60) == b"" -@pytest.mark.asyncio -async def test_redis_empty_session(redis_store: SessionStore) -> None: - assert await redis_store.read("unknown_session_id", lifetime=60) == b"" +async def test_redis_empty_session() -> None: + client = redis.Redis.from_url(REDIS_URL) + redis_store = RedisStore(connection=client) + async with client: + assert await redis_store.read("unknown_session_id", lifetime=60) == b"" -@pytest.mark.asyncio async def test_redis_requires_url_or_connection() -> None: with pytest.raises(ImproperlyConfigured): RedisStore() -@pytest.mark.asyncio async def test_redis_uses_url() -> None: - store = RedisStore(url="redis://") - assert isinstance(store._connection, redis.asyncio.Redis) - - -@pytest.mark.asyncio -async def test_redis_uses_connection() -> None: - connection = redis.asyncio.Redis.from_url("redis://") - store = RedisStore(connection=connection) - assert store._connection == connection + with pytest.warns(DeprecationWarning): + store = RedisStore(url="redis://") + assert isinstance(store._connection, redis.Redis) + await store._connection.aclose() diff --git a/tests/conftest.py b/tests/conftest.py index 7defee8..9cb7eee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,11 +3,11 @@ from starsessions import InMemoryStore, JsonSerializer -@pytest.fixture() +@pytest.fixture def serializer() -> JsonSerializer: return JsonSerializer() -@pytest.fixture() +@pytest.fixture def store() -> InMemoryStore: return InMemoryStore() diff --git a/tests/test_autoloading.py b/tests/test_autoloading.py index e91edd5..dbbf914 100644 --- a/tests/test_autoloading.py +++ b/tests/test_autoloading.py @@ -1,5 +1,6 @@ -import pytest import re + +import pytest from starlette.requests import HTTPConnection from starlette.responses import JSONResponse from starlette.testclient import TestClient diff --git a/tests/test_expiring_session.py b/tests/test_expiring_session.py index 83fffeb..30b3e7a 100644 --- a/tests/test_expiring_session.py +++ b/tests/test_expiring_session.py @@ -1,10 +1,11 @@ -import pytest import time +from unittest import mock + +import pytest from starlette.requests import HTTPConnection from starlette.responses import JSONResponse from starlette.testclient import TestClient from starlette.types import Receive, Scope, Send -from unittest import mock from starsessions import SessionMiddleware, SessionStore, load_session diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 4e7d1d1..2d3dee5 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,10 +1,11 @@ import json +from unittest import mock + import pytest from starlette.requests import HTTPConnection from starlette.responses import JSONResponse from starlette.testclient import TestClient from starlette.types import Receive, Scope, Send -from unittest import mock from starsessions import SessionMiddleware, SessionNotLoaded, SessionStore from starsessions.session import get_session_metadata, load_session diff --git a/tests/test_middleware.py b/tests/test_middleware.py index c785602..e9f7b02 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,4 +1,5 @@ import datetime + import pytest from starlette.requests import HTTPConnection from starlette.responses import JSONResponse, Response diff --git a/tests/test_rolling_session.py b/tests/test_rolling_session.py index a81414a..621fbfe 100644 --- a/tests/test_rolling_session.py +++ b/tests/test_rolling_session.py @@ -1,10 +1,11 @@ -import pytest import time +from unittest import mock + +import pytest from starlette.requests import HTTPConnection from starlette.responses import JSONResponse from starlette.testclient import TestClient from starlette.types import Receive, Scope, Send -from unittest import mock from starsessions import SessionMiddleware, SessionStore, load_session @@ -36,4 +37,6 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None: second_max_age = next(cookie for cookie in response.cookies.jar if cookie.name == "session").expires # the expiration date of the second response must be larger + assert second_max_age + assert first_max_age assert second_max_age > first_max_age