Skip to content
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
16 changes: 6 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
FROM python:3.13-slim

# required for psycopg2
RUN apt update \
&& apt install -y --no-install-recommends \
build-essential \
libpq-dev \
&& apt clean \
&& rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
RUN useradd --no-create-home --gid root runner

ENV UV_PYTHON_PREFERENCE=only-system
ENV UV_NO_CACHE=true
ENV UV_PROJECT_ENVIRONMENT=/code/.venv \
UV_NO_MANAGED_PYTHON=1 \
UV_NO_CACHE=true \
UV_LINK_MODE=copy

WORKDIR /code

Expand All @@ -26,3 +20,5 @@ COPY . .
RUN chown -R runner:root /code && chmod -R g=u /code

USER runner

RUN uv sync --all-extras --frozen
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sh:
docker compose run --service-ports application bash

test *args: down && down
docker compose run application uv run --no-sync pytest {{ args }}
docker compose run application uv run pytest {{ args }}

build:
docker compose build application
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ services:
dockerfile: ./Dockerfile
restart: always
volumes:
- .:/srv/www/
- .:/code
- /code/.venv
depends_on:
redis:
condition: service_healthy
Expand Down
4 changes: 2 additions & 2 deletions redis_timers/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Router:
def handler[T: pydantic.BaseModel](
self,
*,
name: str | None = None,
topic: str,
schema: type[T],
) -> typing.Callable[
[typing.Callable[[T], typing.Coroutine[None, None, None]]],
Expand All @@ -24,7 +24,7 @@ def _decorator(
) -> typing.Callable[[T], typing.Coroutine[None, None, None]]:
self.handlers.append(
Handler(
topic=name or func.__name__,
topic=topic,
schema=schema,
handler=func,
)
Expand Down
21 changes: 4 additions & 17 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ class AnotherSchema(pydantic.BaseModel):
count: int


def test_register_handler_with_name() -> None:
def test_register_handler() -> None:
router: typing.Final = Router()

@router.handler(name="test_timer", schema=SomeSchema)
@router.handler(topic="test_timer", schema=SomeSchema)
async def test_handler(data: SomeSchema) -> None: ...

assert len(router.handlers) == 1
Expand All @@ -26,27 +26,14 @@ async def test_handler(data: SomeSchema) -> None: ...
assert handler.handler == test_handler


def test_register_handler_without_name() -> None:
router: typing.Final = Router()

@router.handler(schema=SomeSchema)
async def my_timer_handler(data: SomeSchema) -> None: ...

assert len(router.handlers) == 1
handler: typing.Final = router.handlers[0]
assert handler.topic == "my_timer_handler"
assert handler.schema == SomeSchema
assert handler.handler == my_timer_handler


def test_register_handler_multiple_handlers() -> None:
router: typing.Final = Router()
expected_handlers_count: typing.Final = 2

@router.handler(name="handler1", schema=SomeSchema)
@router.handler(topic="handler1", schema=SomeSchema)
async def handler1(data: SomeSchema) -> None: ...

@router.handler(name="handler2", schema=AnotherSchema)
@router.handler(topic="handler2", schema=AnotherSchema)
async def handler2(data: AnotherSchema) -> None: ...

assert len(router.handlers) == expected_handlers_count
Expand Down
54 changes: 27 additions & 27 deletions tests/test_timers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
DUPLICATE_TIMER_COUNT = 2


class TestPayloadModel(pydantic.BaseModel):
class SomePayloadModel(pydantic.BaseModel):
message: str
count: int

Expand All @@ -27,9 +27,9 @@ class AnotherPayloadModel(pydantic.BaseModel):

class HandlerResults:
def __init__(self) -> None:
self.results: list[TestPayloadModel | AnotherPayloadModel] = []
self.results: list[SomePayloadModel | AnotherPayloadModel] = []

def add_result(self, result: TestPayloadModel | AnotherPayloadModel) -> None:
def add_result(self, result: SomePayloadModel | AnotherPayloadModel) -> None:
self.results.append(result)

def clear(self) -> None:
Expand All @@ -43,7 +43,7 @@ async def redis_client() -> AsyncGenerator["aioredis.Redis[str]"]:
await client.delete(settings.TIMERS_TIMELINE_KEY, settings.TIMERS_PAYLOADS_KEY)
yield client
finally:
await client.close()
await client.aclose() # type: ignore[attr-defined]


@pytest.fixture
Expand All @@ -57,13 +57,13 @@ async def handler_results() -> AsyncGenerator[HandlerResults]:
def timers_instance(redis_client: "aioredis.Redis[str]", handler_results: HandlerResults) -> Timers:
router1 = Router()

@router1.handler(schema=TestPayloadModel)
async def test_handler(data: TestPayloadModel) -> None:
@router1.handler(topic="some_topic", schema=SomePayloadModel)
async def test_handler(data: SomePayloadModel) -> None:
handler_results.add_result(data)

router2 = Router()

@router2.handler(name="another_topic", schema=AnotherPayloadModel)
@router2.handler(topic="another_topic", schema=AnotherPayloadModel)
async def another_handler(data: AnotherPayloadModel) -> None:
handler_results.add_result(data)

Expand All @@ -75,25 +75,25 @@ async def another_handler(data: AnotherPayloadModel) -> None:


async def test_set_and_remove_timer(timers_instance: Timers) -> None:
payload = TestPayloadModel(message="test", count=1)
payload = SomePayloadModel(message="test", count=1)
await timers_instance.set_timer(
topic="test_handler", timer_id="test_timer_1", payload=payload, activation_period=datetime.timedelta(seconds=1)
topic="some_topic", timer_id="test_timer_1", payload=payload, activation_period=datetime.timedelta(seconds=1)
)

# Check that timer exists in Redis
timeline_keys, payloads_dict = await timers_instance.fetch_all_timers()
assert len(timeline_keys) == 1
timer_key = timeline_keys[0]
assert timer_key == "test_handler--test_timer_1"
assert timer_key == "some_topic--test_timer_1"

# Check payloads has the timer data
assert timer_key in payloads_dict
payload_data = payloads_dict[timer_key]
parsed_payload = TestPayloadModel.model_validate_json(payload_data)
parsed_payload = SomePayloadModel.model_validate_json(payload_data)
assert parsed_payload == payload

# Remove the timer
await timers_instance.remove_timer(topic="test_handler", timer_id="test_timer_1")
await timers_instance.remove_timer(topic="some_topic", timer_id="test_timer_1")

# Check that timer is removed from Redis
timeline_keys, payloads_dict = await timers_instance.fetch_all_timers()
Expand All @@ -102,9 +102,9 @@ async def test_set_and_remove_timer(timers_instance: Timers) -> None:


async def test_handle_ready_timers(timers_instance: Timers, handler_results: HandlerResults) -> None:
payload = TestPayloadModel(message="ready_timer", count=42)
payload = SomePayloadModel(message="ready_timer", count=42)
await timers_instance.set_timer(
topic="test_handler",
topic="some_topic",
timer_id="ready_timer_1",
payload=payload,
activation_period=datetime.timedelta(seconds=0), # Ready immediately
Expand All @@ -117,7 +117,7 @@ async def test_handle_ready_timers(timers_instance: Timers, handler_results: Han
assert handler_results.results
assert len(handler_results.results) == 1
result = handler_results.results[0]
assert isinstance(result, TestPayloadModel)
assert isinstance(result, SomePayloadModel)
assert result == payload

# Check that timer was removed from Redis
Expand All @@ -127,11 +127,11 @@ async def test_handle_ready_timers(timers_instance: Timers, handler_results: Han


async def test_handle_multiple_ready_timers(timers_instance: Timers, handler_results: HandlerResults) -> None:
payload1 = TestPayloadModel(message="timer_1", count=1)
payload1 = SomePayloadModel(message="timer_1", count=1)
payload2 = AnotherPayloadModel(value="timer_2")

await timers_instance.set_timer(
topic="test_handler",
topic="some_topic",
timer_id="multi_timer_1",
payload=payload1,
activation_period=datetime.timedelta(seconds=0),
Expand All @@ -150,9 +150,9 @@ async def test_handle_multiple_ready_timers(timers_instance: Timers, handler_res


async def test_timer_not_ready_yet(timers_instance: Timers, handler_results: HandlerResults) -> None:
payload = TestPayloadModel(message="future_timer", count=99)
payload = SomePayloadModel(message="future_timer", count=99)
await timers_instance.set_timer(
topic="test_handler",
topic="some_topic",
timer_id="future_timer_1",
payload=payload,
activation_period=datetime.timedelta(seconds=10),
Expand All @@ -163,16 +163,16 @@ async def test_timer_not_ready_yet(timers_instance: Timers, handler_results: Han
assert len(handler_results.results) == 0
timeline_keys, payloads_dict = await timers_instance.fetch_all_timers()
assert len(timeline_keys) == 1
timer_key = "test_handler--future_timer_1"
timer_key = "some_topic--future_timer_1"
assert timer_key in payloads_dict


async def test_remove_nonexistent_timer(timers_instance: Timers) -> None:
await timers_instance.remove_timer(topic="test_handler", timer_id="nonexistent_timer")
await timers_instance.remove_timer(topic="some_topic", timer_id="nonexistent_timer")


async def test_set_timer_with_invalid_topic(timers_instance: Timers) -> None:
payload = TestPayloadModel(message="test", count=1)
payload = SomePayloadModel(message="test", count=1)

with pytest.raises(RuntimeError, match="Handler is not found"):
await timers_instance.set_timer(
Expand All @@ -194,17 +194,17 @@ async def test_empty_timeline_handling(timers_instance: Timers, handler_results:


async def test_duplicate_timer_replacement(timers_instance: Timers, handler_results: HandlerResults) -> None:
payload1 = TestPayloadModel(message="first", count=1)
payload1 = SomePayloadModel(message="first", count=1)
await timers_instance.set_timer(
topic="test_handler",
topic="some_topic",
timer_id="duplicate_timer",
payload=payload1,
activation_period=datetime.timedelta(seconds=10), # Far in future
)

payload2 = TestPayloadModel(message="second", count=2)
payload2 = SomePayloadModel(message="second", count=2)
await timers_instance.set_timer(
topic="test_handler",
topic="some_topic",
timer_id="duplicate_timer",
payload=payload2,
activation_period=datetime.timedelta(seconds=0), # Ready immediately
Expand All @@ -214,5 +214,5 @@ async def test_duplicate_timer_replacement(timers_instance: Timers, handler_resu

assert len(handler_results.results) == 1
result = handler_results.results[0]
assert isinstance(result, TestPayloadModel)
assert isinstance(result, SomePayloadModel)
assert result == payload2
Loading