Skip to content

Commit

Permalink
add redis extension
Browse files Browse the repository at this point in the history
  • Loading branch information
livioribeiro committed Feb 12, 2024
1 parent b53c73a commit 2fd687d
Show file tree
Hide file tree
Showing 22 changed files with 687 additions and 87 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ jobs:
- uses: actions/setup-python@v4
with:
python-version: 3.x
- run: pip install "mkdocs-material>=9.4,<9.5"
- run: pip install "mkdocs-material>=9.5,<9.6"
- run: mkdocs gh-deploy --force
24 changes: 18 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ on:
branches:
- main

env:
POSTGRES_DB: test_db

jobs:
test:
runs-on: ubuntu-latest
Expand All @@ -28,16 +25,31 @@ jobs:
image: postgres:16.1-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ${{ env.POSTGRES_DB }}
POSTGRES_DB: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: bitnami/redis:7.2.4
env:
REDIS_PASSWORD: password
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: pip install poetry
- run: poetry install --with test --extras jinja --extras sqlalchemy --no-interaction
- run: poetry install
--with test
--extras jinja
--extras sqlalchemy
--extras redis
--no-interaction
- env:
POSTGRES_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/${{ env.POSTGRES_DB }}
POSTGRES_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/test
REDIS_URL: redis://default:password@redis:6379/0
run: poetry run pytest
Empty file.
17 changes: 17 additions & 0 deletions examples/redis/application/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import Annotated

from asgikit.responses import respond_json
from selva.di import Inject
from selva.web import controller, get

from .service import RedisService


@controller
class Controller:
redis_service: Annotated[RedisService, Inject]

@get
async def index(self, request):
number = await self.redis_service.get_incr()
await respond_json(request.response, {"number": number})
17 changes: 17 additions & 0 deletions examples/redis/application/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import Annotated

from redis.asyncio import Redis

from selva.di import service, Inject


@service
class RedisService:
redis: Annotated[Redis, Inject]

async def initialize(self):
await self.redis.set("number", 0)

async def get_incr(self) -> int:
return await self.redis.incr("number")

16 changes: 16 additions & 0 deletions examples/redis/configuration/settings.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
extensions:
- selva.ext.data.redis

data:
redis:
default:
host: "localhost"
options:
retry:
retries: 1
backoff:
constant:
backoff: 1

other:
url: "redis://localhost:6379/1"
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ python-dotenv = "^1.0"
"ruamel.yaml" = "^0.18"
jinja2 = { version = "^3.1", optional = true }
SQLAlchemy = { version = "^2.0", optional = true }
redis = { version = "^5.0", optional = true }

[tool.poetry.extras]
jinja = ["jinja2"]
sqlalchemy = ["SQLAlchemy"]
redis = ["redis"]

[tool.poetry.group.dev]
optional = true
Expand Down Expand Up @@ -74,7 +76,7 @@ ruff = "^0.2"
optional = true

[tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.4"
mkdocs-material = "^9.5"

[tool.pytest.ini_options]
asyncio_mode = "auto"
Expand Down
5 changes: 4 additions & 1 deletion src/selva/configuration/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@
"paths": ["resources/templates"],
"jinja": {},
},
"data": {"sqlalchemy": {}},
"data": {
"redis": {},
"sqlalchemy": {},
},
}
16 changes: 16 additions & 0 deletions src/selva/ext/data/redis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from importlib.util import find_spec

from selva.configuration.settings import Settings
from selva.di.container import Container

from .service import make_service


def selva_extension(container: Container, settings: Settings):
if find_spec("redis") is None:
return

for name in settings.data.redis:
service_name = name if name != "default" else None

container.register(make_service(name), name=service_name)
70 changes: 70 additions & 0 deletions src/selva/ext/data/redis/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from redis.asyncio import Redis
from redis.backoff import (
AbstractBackoff,
ConstantBackoff,
DecorrelatedJitterBackoff,
EqualJitterBackoff,
ExponentialBackoff,
FullJitterBackoff,
NoBackoff,
)
from redis.retry import Retry

from selva.configuration.settings import Settings
from selva.ext.data.redis.settings import RedisSettings

from .settings import BackoffSchema, RetrySchema


def build_backoff(data: BackoffSchema) -> AbstractBackoff:
if "no_backoff" in data.model_fields_set:
return NoBackoff()

if value := data.constant:
return ConstantBackoff(**value.model_dump(exclude_unset=True))

if value := data.exponential:
return ExponentialBackoff(**value.model_dump(exclude_unset=True))

if value := data.full_jitter:
return FullJitterBackoff(**value.model_dump(exclude_unset=True))

if value := data.equal_jitter:
return EqualJitterBackoff(**value.model_dump(exclude_unset=True))

if value := data.decorrelated_jitter:
return DecorrelatedJitterBackoff(**value.model_dump(exclude_unset=True))


def build_retry(data: RetrySchema):
kwargs = {
"backoff": build_backoff(data.backoff),
"retries": data.retries,
}

if supported_errors := data.supported_errors:
kwargs["supported_errors"] = supported_errors

return Retry(**kwargs)


def make_service(name: str):
async def redis_service(
settings: Settings,
) -> Redis:
redis_settings = RedisSettings.model_validate(dict(settings.data.redis[name]))

kwargs = redis_settings.model_dump(exclude_unset=True)
if (options := redis_settings.options) and (retry := options.retry):
kwargs["retry"] = build_retry(retry)

if url := kwargs.pop("url", ""):
redis = Redis.from_url(url, **kwargs)
else:
redis = Redis(**kwargs)

await redis.initialize()
yield redis
await redis.aclose()

return redis_service
103 changes: 103 additions & 0 deletions src/selva/ext/data/redis/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from collections.abc import Callable
from types import NoneType
from typing import Self, Type

from pydantic import BaseModel, model_serializer, model_validator
from redis import RedisError
from redis.credentials import CredentialProvider

from selva._util.pydantic import DottedPath


class ConstantBackoffSchema(BaseModel):
backoff: int


class ExponentialBackoffSchema(BaseModel):
cap: int = None
base: int = None


class BackoffSchema(BaseModel):
no_backoff: NoneType = None
constant: ConstantBackoffSchema = None
exponential: ExponentialBackoffSchema = None
full_jitter: ExponentialBackoffSchema = None
equal_jitter: ExponentialBackoffSchema = None
decorrelated_jitter: ExponentialBackoffSchema = None

@model_validator(mode="before")
def validator(cls, data):
if len(data) != 1:
raise ValueError("Only one backoff value can be set")

return data


class RetrySchema(BaseModel):
backoff: BackoffSchema
retries: int
supported_errors: tuple[DottedPath[Type[RedisError]], ...] = None


class RedisOptions(BaseModel):
socket_timeout: float = None
socket_connect_timeout: float = None
socket_keepalive: bool = None
socket_keepalive_options: dict[int, int | bytes] = None
unix_socket_path: str = None
encoding: str = None
encoding_errors: str = None
decode_responses: bool = None
retry_on_timeout: bool = None
retry_on_error: list = None
ssl: bool = None
ssl_keyfile: str = None
ssl_certfile: str = None
ssl_cert_reqs: str = None
ssl_ca_certs: str = None
ssl_ca_data: str = None
ssl_check_hostname: bool = None
max_connections: int = None
single_connection_client: bool = None
health_check_interval: int = None
client_name: str = None
lib_name: str = None
lib_version: str = None
retry: RetrySchema = None
auto_close_connection_pool: bool = None
redis_connect_func: DottedPath[Callable] = None
credential_provider: DottedPath[CredentialProvider] = None
protocol: int = None


class RedisSettings(BaseModel):
url: str = None
host: str = None
port: int = None
db: int = None
username: str = None
password: str = None
options: RedisOptions = None

@model_validator(mode="after")
def validator(self) -> Self:
if self.url and (self.host or self.port or (self.db is not None)):
raise ValueError(
"Either 'url' should be provided, or 'host', 'port' and 'db'"
)

return self

@model_serializer(when_used="unless-none")
def serializer(self):
data = {
field: getattr(self, field)
for field in self.model_fields_set
if field != "options"
}

if self.options:
data |= self.options.model_dump(exclude_unset=True)

return data
6 changes: 2 additions & 4 deletions src/selva/ext/data/sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

from selva.configuration.settings import Settings
from selva.di.container import Container
from selva.ext.data.sqlalchemy.service import (
make_engine_service,
make_sessionmaker_service,
)

from .service import make_engine_service, make_sessionmaker_service


def selva_extension(container: Container, settings: Settings):
Expand Down
Loading

0 comments on commit 2fd687d

Please sign in to comment.