Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Explicit Redis connection closing #77

Merged
merged 1 commit into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/lint_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,5 @@ repos:
additional_dependencies:
- pytest-asyncio
- redis
- types-redis
- itsdangerous
- fastapi
42 changes: 27 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from sqlite3 import connect

## Starsessions

Advanced sessions for Starlette and FastAPI frameworks
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions examples/expiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import datetime
import json

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
Expand Down
1 change: 1 addition & 0 deletions examples/fastapi_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import datetime
import typing

from fastapi import FastAPI
from fastapi.requests import Request
from fastapi.responses import JSONResponse, RedirectResponse
Expand Down
4 changes: 2 additions & 2 deletions examples/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ async def profile(request: Request) -> Response:
return RedirectResponse("/", 302)

return HTMLResponse(
"""
f"""
<p>Hi, {username}!</p>
<form method="post" action="/logout">
<button type="submit">logout</button>
</form>
""".format(username=username)
"""
)


Expand Down
17 changes: 15 additions & 2 deletions examples/redis_.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions examples/rolling.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import datetime
import json

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
Expand Down
1 change: 1 addition & 0 deletions examples/session_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import datetime
import json

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
Expand Down
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions starsessions/middleware.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions starsessions/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions starsessions/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import secrets
import time
import typing

from starlette.requests import HTTPConnection

from starsessions.exceptions import SessionNotLoaded
Expand Down
6 changes: 3 additions & 3 deletions starsessions/stores/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -39,4 +39,4 @@ async def remove(self, session_id: str) -> None:

:param session_id: ID associated with session
"""
raise NotImplementedError()
raise NotImplementedError
1 change: 1 addition & 0 deletions starsessions/stores/cookie.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import typing
from base64 import b64decode, b64encode

from itsdangerous import BadSignature, TimestampSigner
from starlette.datastructures import Secret

Expand Down
34 changes: 15 additions & 19 deletions starsessions/stores/redis.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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))
Loading
Loading