diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 511f371..d7ba5f5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -42,9 +42,9 @@ jobs: - name: Set up PostgreSQL uses: ikalnytskyi/action-setup-postgres@v8 with: - username: postgres - password: postgres - database: taskiqpsqlpy + username: taskiq_psqlpy + password: look_in_vault + database: taskiq_psqlpy id: postgres - name: Set up uv and enable cache id: setup-uv diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..694896f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + postgres: + container_name: taskiq_psqlpy + image: postgres:18 + environment: + POSTGRES_DB: taskiq_psqlpy + POSTGRES_USER: taskiq_psqlpy + POSTGRES_PASSWORD: look_in_vault + ports: + - "5432:5432" diff --git a/pyproject.toml b/pyproject.toml index cf4bd58..6a56097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,13 +42,15 @@ dev = [ {include-group = "lint"}, {include-group = "test"}, "pre-commit>=4.5.0", - "anyio>=4.12.0", ] test = [ "pytest>=9.0.1", "pytest-cov>=7.0.0", "pytest-env>=1.2.0", "pytest-xdist>=3.8.0", + "pytest-asyncio>=1.3.0", + "polyfactory>=3.1.0", + "sqlalchemy-utils>=0.42.1", ] lint = [ "black>=25.11.0", @@ -80,7 +82,7 @@ module-root = "" module-name = "taskiq_psqlpy" [tool.ruff] -line-length = 88 +line-length = 120 [tool.ruff.lint] # List of enabled rulsets. @@ -147,3 +149,14 @@ allow-magic-value-types = ["int", "str", "float"] [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = ["taskiq_dependencies.Depends", "taskiq.TaskiqDepends"] + +[tool.pytest.ini_options] +pythonpath = [ + "." +] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +markers = [ + "unit: marks unit tests", + "integration: marks tests with real infrastructure env", +] diff --git a/taskiq_psqlpy/__init__.py b/taskiq_psqlpy/__init__.py index 8c4db26..ba0f1e0 100644 --- a/taskiq_psqlpy/__init__.py +++ b/taskiq_psqlpy/__init__.py @@ -1,5 +1,9 @@ +from taskiq_psqlpy.broker import PSQLPyBroker from taskiq_psqlpy.result_backend import PSQLPyResultBackend +from taskiq_psqlpy.schedule_source import PSQLPyScheduleSource __all__ = [ + "PSQLPyBroker", "PSQLPyResultBackend", + "PSQLPyScheduleSource", ] diff --git a/taskiq_psqlpy/broker.py b/taskiq_psqlpy/broker.py new file mode 100644 index 0000000..1d4c3b5 --- /dev/null +++ b/taskiq_psqlpy/broker.py @@ -0,0 +1,225 @@ +import asyncio +import logging +import typing as tp +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from datetime import datetime + +import psqlpy +from psqlpy.exceptions import ConnectionExecuteError +from psqlpy.extra_types import JSONB +from taskiq import AckableMessage, AsyncBroker, AsyncResultBackend, BrokerMessage + +from taskiq_psqlpy.queries import ( + CLAIM_MESSAGE_QUERY, + CREATE_MESSAGE_TABLE_QUERY, + DELETE_MESSAGE_QUERY, + INSERT_MESSAGE_QUERY, +) + +logger = logging.getLogger("taskiq.psqlpy_broker") +_T = tp.TypeVar("_T") + + +@dataclass +class MessageRow: + """Message in db table.""" + + id: int + task_id: str + task_name: str + message: str + labels: JSONB + status: str + created_at: datetime + + +class PSQLPyBroker(AsyncBroker): + """Broker that uses PostgreSQL and PSQLPy with LISTEN/NOTIFY.""" + + _read_conn: psqlpy.Connection + _write_pool: psqlpy.ConnectionPool + _listener: psqlpy.Listener + + def __init__( + self, + dsn: ( + str | tp.Callable[[], str] + ) = "postgresql://taskiq_psqlpy:look_in_vault@localhost:5432/taskiq_psqlpy", + result_backend: AsyncResultBackend[_T] | None = None, + task_id_generator: tp.Callable[[], str] | None = None, + channel_name: str = "taskiq", + table_name: str = "taskiq_messages", + max_retry_attempts: int = 5, + read_kwargs: dict[str, tp.Any] | None = None, + write_kwargs: dict[str, tp.Any] | None = None, + ) -> None: + """ + Construct a new broker. + + Args: + dsn: connection string to PostgreSQL, or callable returning one. + result_backend: Custom result backend. + task_id_generator: Custom task_id generator. + channel_name: Name of the channel to listen on. + table_name: Name of the table to store messages. + max_retry_attempts: Maximum number of message processing attempts. + read_kwargs: Additional arguments for read connection creation. + write_kwargs: Additional arguments for write pool creation. + + """ + super().__init__( + result_backend=result_backend, + task_id_generator=task_id_generator, + ) + self._dsn: str | tp.Callable[[], str] = dsn + self.channel_name: str = channel_name + self.table_name: str = table_name + self.read_kwargs: dict[str, tp.Any] = read_kwargs or {} + self.write_kwargs: dict[str, tp.Any] = write_kwargs or {} + self.max_retry_attempts: int = max_retry_attempts + self._queue: asyncio.Queue[str] | None = None + + @property + def dsn(self) -> str: + """ + Get the DSN string. + + Returns: + A string with dsn or None if dsn isn't set yet. + + """ + if callable(self._dsn): + return self._dsn() + return self._dsn + + async def startup(self) -> None: + """Initialize the broker.""" + await super().startup() + self._read_conn = await psqlpy.connect( + dsn=self.dsn, + **self.read_kwargs, + ) + self._write_pool = psqlpy.ConnectionPool( + dsn=self.dsn, + **self.write_kwargs, + ) + + # create messages table if it doesn't exist + async with self._write_pool.acquire() as conn: + await conn.execute(CREATE_MESSAGE_TABLE_QUERY.format(self.table_name)) + + # listen to notification channel + self._listener = self._write_pool.listener() + await self._listener.add_callback(self.channel_name, self._notification_handler) + await self._listener.startup() + self._listener.listen() + + self._queue = asyncio.Queue() + + async def shutdown(self) -> None: + """Close all connections on shutdown.""" + await super().shutdown() + if self._read_conn is not None: + self._read_conn.close() + if self._write_pool is not None: + self._write_pool.close() + if self._listener is not None: + self._listener.abort_listen() + await self._listener.shutdown() + + async def _notification_handler( + self, + connection: psqlpy.Connection, + payload: str, + channel: str, + process_id: int, + ) -> None: + """ + Handle NOTIFY messages. + + https://psqlpy-python.github.io/components/listener.html#usage + """ + logger.debug("Received notification on channel %s: %s", channel, payload) + if self._queue is not None: + self._queue.put_nowait(payload) + + async def kick(self, message: BrokerMessage) -> None: + """ + Send message to the channel. + + Inserts the message into the database and sends a NOTIFY. + + :param message: Message to send. + """ + async with self._write_pool.acquire() as conn: + # insert message into db table + message_inserted_id = tp.cast( + "int", + await conn.fetch_val( + INSERT_MESSAGE_QUERY.format(self.table_name), + [ + message.task_id, + message.task_name, + message.message.decode(), + JSONB(message.labels), + ], + ), + ) + + delay_value = tp.cast("str | None", message.labels.get("delay")) + if delay_value is not None: + delay_seconds = int(delay_value) + asyncio.create_task( # noqa: RUF006 + self._schedule_notification(message_inserted_id, delay_seconds), + ) + else: + # Send NOTIFY with message ID as payload + _ = await conn.execute( + f"NOTIFY {self.channel_name}, '{message_inserted_id}'", + ) + + async def _schedule_notification(self, message_id: int, delay_seconds: int) -> None: + """Schedule a notification to be sent after a delay.""" + await asyncio.sleep(delay_seconds) + async with self._write_pool.acquire() as conn: + # Send NOTIFY with message ID as payload + _ = await conn.execute(f"NOTIFY {self.channel_name}, '{message_id}'") + + async def listen(self) -> AsyncGenerator[AckableMessage, None]: + """ + Listen to the channel. + + Yields messages as they are received. + + :yields: AckableMessage instances. + """ + while True: + try: + payload = await self._queue.get() # type: ignore[union-attr] + message_id = int(payload) # payload is the message id + try: + async with self._write_pool.acquire() as conn: + claimed_message = await conn.fetch_row( + CLAIM_MESSAGE_QUERY.format(self.table_name), + [message_id], + ) + except ConnectionExecuteError: # message was claimed by another worker + continue + message_row_result = tp.cast( + "MessageRow", + tp.cast("object", claimed_message.as_class(MessageRow)), + ) + message_data = message_row_result.message.encode() + + async def ack(*, _message_id: int = message_id) -> None: + async with self._write_pool.acquire() as conn: + _ = await conn.execute( + DELETE_MESSAGE_QUERY.format(self.table_name), + [_message_id], + ) + + yield AckableMessage(data=message_data, ack=ack) + except Exception: + logger.exception("Error processing message") + continue diff --git a/taskiq_psqlpy/queries.py b/taskiq_psqlpy/queries.py index 616adce..1c5595e 100644 --- a/taskiq_psqlpy/queries.py +++ b/taskiq_psqlpy/queries.py @@ -29,3 +29,57 @@ DELETE_RESULT_QUERY = """ DELETE FROM {} WHERE task_id = $1 """ + +CREATE_SCHEDULES_TABLE_QUERY = """ +CREATE TABLE IF NOT EXISTS {} ( + id UUID PRIMARY KEY, + task_name VARCHAR(100) NOT NULL, + schedule JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +""" + +INSERT_SCHEDULE_QUERY = """ +INSERT INTO {} (id, task_name, schedule) +VALUES ($1, $2, $3) +ON CONFLICT (id) DO UPDATE +SET task_name = EXCLUDED.task_name, + schedule = EXCLUDED.schedule, + updated_at = NOW(); +""" + +SELECT_SCHEDULES_QUERY = """ +SELECT id, task_name, schedule +FROM {}; +""" + +DELETE_ALL_SCHEDULES_QUERY = """ +DELETE FROM {}; +""" + +DELETE_SCHEDULE_QUERY = """ +DELETE FROM {} WHERE id = $1; +""" + +CREATE_MESSAGE_TABLE_QUERY = """ +CREATE TABLE IF NOT EXISTS {} ( + id SERIAL PRIMARY KEY, + task_id VARCHAR NOT NULL, + task_name VARCHAR NOT NULL, + message TEXT NOT NULL, + labels JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +""" + +INSERT_MESSAGE_QUERY = """ +INSERT INTO {} (task_id, task_name, message, labels) +VALUES ($1, $2, $3, $4) +RETURNING id +""" + +CLAIM_MESSAGE_QUERY = "UPDATE {} SET status = 'processing' WHERE id = $1 AND status = 'pending' RETURNING *" + +DELETE_MESSAGE_QUERY = "DELETE FROM {} WHERE id = $1" diff --git a/taskiq_psqlpy/result_backend.py b/taskiq_psqlpy/result_backend.py index 20fd4c8..4eb2a8f 100644 --- a/taskiq_psqlpy/result_backend.py +++ b/taskiq_psqlpy/result_backend.py @@ -32,7 +32,9 @@ class PSQLPyResultBackend(AsyncResultBackend[_ReturnType]): def __init__( self, - dsn: str | None = "postgres://postgres:postgres@localhost:5432/postgres", + dsn: ( + str | None + ) = "postgresql://taskiq_psqlpy:look_in_vault@localhost:5432/taskiq_psqlpy", keep_results: bool = True, table_name: str = "taskiq_results", field_for_task_id: Literal["VarChar", "Text"] = "VarChar", diff --git a/taskiq_psqlpy/schedule_source.py b/taskiq_psqlpy/schedule_source.py new file mode 100644 index 0000000..aea5979 --- /dev/null +++ b/taskiq_psqlpy/schedule_source.py @@ -0,0 +1,230 @@ +import uuid +from collections.abc import Callable +from logging import getLogger +from typing import Any, Final + +from psqlpy import ConnectionPool +from psqlpy.extra_types import JSONB +from pydantic import ValidationError +from taskiq import AsyncBroker, ScheduledTask, ScheduleSource + +from taskiq_psqlpy.queries import ( + CREATE_SCHEDULES_TABLE_QUERY, + DELETE_ALL_SCHEDULES_QUERY, + DELETE_SCHEDULE_QUERY, + INSERT_SCHEDULE_QUERY, + SELECT_SCHEDULES_QUERY, +) + +logger = getLogger("taskiq_pg.psqlpy_schedule_source") + + +class PSQLPyScheduleSource(ScheduleSource): + """Schedule source that uses psqlpy to store schedules in PostgreSQL.""" + + _database_pool: ConnectionPool + + def __init__( + self, + broker: AsyncBroker, + dsn: ( + str | Callable[[], str] + ) = "postgresql://taskiq_psqlpy:look_in_vault@localhost:5432/taskiq_psqlpy", + table_name: str = "taskiq_schedules", + **connect_kwargs: Any, + ) -> None: + """ + Initialize the PostgreSQL scheduler source. + + Sets up a scheduler source that stores scheduled tasks in a PostgreSQL database. + This scheduler source manages task schedules, allowing for persistent storage and retrieval of scheduled tasks + across application restarts. + + Args: + dsn: PostgreSQL connection string + table_name: Name of the table to store scheduled tasks. Will be created automatically if it doesn't exist. + broker: The TaskIQ broker instance to use for finding and managing tasks. + Required if startup_schedule is provided. + **connect_kwargs: Additional keyword arguments passed to the database connection pool. + + """ + self._broker: Final = broker + self._dsn: Final = dsn + self._table_name: Final = table_name + self._connect_kwargs: Final = connect_kwargs + + @property + def dsn(self) -> str | None: + """ + Get the DSN string. + + Returns the DSN string or None if not set. + """ + if callable(self._dsn): + return self._dsn() + return self._dsn + + def _extract_scheduled_tasks_from_broker(self) -> list[ScheduledTask]: + """ + Extract schedules from tasks that were registered in broker. + + Returns: + A list of ScheduledTask instances extracted from the task's labels. + """ + scheduled_tasks_for_creation: list[ScheduledTask] = [] + for task_name, task in self._broker.get_all_tasks().items(): + if "schedule" not in task.labels: + logger.debug("Task %s has no schedule, skipping", task_name) + continue + if not isinstance(task.labels["schedule"], list): + logger.warning( + "Schedule for task %s is not a list, skipping", + task_name, + ) + continue + for schedule in task.labels["schedule"]: + try: + new_schedule = ScheduledTask.model_validate( + { + "task_name": task_name, + "labels": schedule.get("labels", {}), + "args": schedule.get("args", []), + "kwargs": schedule.get("kwargs", {}), + "schedule_id": str(uuid.uuid4()), + "cron": schedule.get("cron", None), + "cron_offset": schedule.get("cron_offset", None), + "interval": schedule.get("interval", None), + "time": schedule.get("time", None), + }, + ) + scheduled_tasks_for_creation.append(new_schedule) + except ValidationError: + logger.exception( + "Schedule for task %s is not valid, skipping", + task_name, + ) + continue + return scheduled_tasks_for_creation + + async def _update_schedules_on_startup( + self, + schedules: list[ScheduledTask], + ) -> None: + """Update schedules in the database on startup: truncate table and insert new ones.""" + async with ( + self._database_pool.acquire() as connection, + connection.transaction(), + ): + await connection.execute( + DELETE_ALL_SCHEDULES_QUERY.format(self._table_name), + ) + data_to_insert = [] + for schedule in schedules: + schedule_dict = schedule.model_dump( + mode="json", + exclude={"schedule_id", "task_name"}, + ) + data_to_insert.append( + [ + uuid.UUID(schedule.schedule_id), + schedule.task_name, + JSONB(schedule_dict), + ], + ) + + await connection.execute_many( + INSERT_SCHEDULE_QUERY.format(self._table_name), + data_to_insert, + ) + + async def startup(self) -> None: + """ + Initialize the schedule source. + + Construct new connection pool, create new table for schedules if not exists + and fill table with schedules from task labels. + """ + self._database_pool = ConnectionPool( + dsn=self.dsn, + **self._connect_kwargs, + ) + async with self._database_pool.acquire() as connection: + await connection.execute( + CREATE_SCHEDULES_TABLE_QUERY.format( + self._table_name, + ), + ) + scheduled_tasks_for_creation = self._extract_scheduled_tasks_from_broker() + await self._update_schedules_on_startup(scheduled_tasks_for_creation) + + async def shutdown(self) -> None: + """Close the connection pool.""" + if getattr(self, "_database_pool", None) is not None: + self._database_pool.close() + + async def get_schedules(self) -> list["ScheduledTask"]: + """Fetch schedules from the database.""" + async with self._database_pool.acquire() as connection: + rows_with_schedules = await connection.fetch( + SELECT_SCHEDULES_QUERY.format(self._table_name), + ) + schedules = [] + for row in rows_with_schedules.result(): + schedule = row["schedule"] + schedules.append( + ScheduledTask.model_validate( + { + "schedule_id": str(row["id"]), + "task_name": row["task_name"], + "labels": schedule["labels"], + "args": schedule["args"], + "kwargs": schedule["kwargs"], + "cron": schedule["cron"], + "cron_offset": schedule["cron_offset"], + "time": schedule["time"], + "interval": schedule["interval"], + }, + ), + ) + return schedules + + async def add_schedule(self, schedule: "ScheduledTask") -> None: + """ + Add a new schedule. + + Args: + schedule: schedule to add. + """ + async with self._database_pool.acquire() as connection: + schedule_dict = schedule.model_dump( + mode="json", + exclude={"schedule_id", "task_name"}, + ) + await connection.execute( + INSERT_SCHEDULE_QUERY.format(self._table_name), + [ + uuid.UUID(schedule.schedule_id), + schedule.task_name, + JSONB(schedule_dict), + ], + ) + + async def delete_schedule(self, schedule_id: str) -> None: + """ + Method to delete schedule by id. + + This is useful for schedule cancelation. + + Args: + schedule_id: id of schedule to delete. + """ + async with self._database_pool.acquire() as connection: + await connection.execute( + DELETE_SCHEDULE_QUERY.format(self._table_name), + [uuid.UUID(schedule_id)], + ) + + async def post_send(self, task: ScheduledTask) -> None: + """Delete a task after it's completed.""" + if task.time is not None: + await self.delete_schedule(task.schedule_id) diff --git a/tests/conftest.py b/tests/conftest.py index 4635012..83307ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,17 +11,6 @@ _ReturnType = TypeVar("_ReturnType") -@pytest.fixture(scope="session") -def anyio_backend() -> str: - """ - Anyio backend. - - Backend for anyio pytest plugin. - :return: backend name. - """ - return "asyncio" - - @pytest.fixture def postgres_table() -> str: """ @@ -46,11 +35,11 @@ def postgresql_dsn() -> str: """ return ( os.environ.get("POSTGRESQL_URL") - or "postgresql://postgres:postgres@localhost:5432/taskiqpsqlpy" + or "postgresql://taskiq_psqlpy:look_in_vault@localhost:5432/taskiq_psqlpy" ) -@pytest.fixture() +@pytest.fixture async def psqlpy_result_backend( postgresql_dsn: str, postgres_table: str, diff --git a/tests/test_schedule_source.py b/tests/test_schedule_source.py new file mode 100644 index 0000000..6721634 --- /dev/null +++ b/tests/test_schedule_source.py @@ -0,0 +1,183 @@ +import uuid +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from datetime import timedelta +from typing import Any + +import pytest +from polyfactory.factories.pydantic_factory import ModelFactory +from sqlalchemy_utils.types.enriched_datetime.arrow_datetime import datetime +from taskiq import ScheduledTask + +from taskiq_psqlpy import PSQLPyBroker, PSQLPyScheduleSource + + +class ScheduledTaskFactory(ModelFactory[ScheduledTask]): + """Factory for ScheduledTask.""" + + __model__ = ScheduledTask + __check_model__ = True + + @classmethod + def schedule_id(cls) -> str: + """Generate unique schedule ID.""" + return uuid.uuid4().hex + + +@asynccontextmanager +async def _get_schedule_source( + broker: PSQLPyBroker, + dsn: str, +) -> AsyncGenerator[PSQLPyScheduleSource, Any]: + schedule_source = PSQLPyScheduleSource(broker, dsn) + try: + yield schedule_source + finally: + await schedule_source.shutdown() + + +@pytest.fixture +def broker_with_scheduled_tasks(postgresql_dsn: str) -> PSQLPyBroker: + """Test broker with two tasks: one with one schedule and second with two schedules.""" + broker = PSQLPyBroker(dsn=postgresql_dsn) + + @broker.task( + task_name="tests:two_schedules", + schedule=[ + { + "cron": "*/10 * * * *", + "cron_offset": "Europe/Berlin", + "time": None, + "args": [42], + "kwargs": {"x": 10}, + "labels": {"foo": "bar"}, + }, + { + "cron": "0 1 * * *", + "cron_offset": timedelta(hours=1), + "time": None, + "args": [], + "kwargs": {}, + "labels": {}, + }, + ], + ) + async def _two_schedules() -> None: + return None + + @broker.task( + task_name="tests:one_schedule", + schedule=[ + { + "cron_offset": None, + "time": datetime(2024, 1, 1, 12, 0, 0), + "args": [], + "kwargs": {}, + "labels": {}, + }, + ], + ) + async def _one_schedule() -> None: + return None + + @broker.task( + task_name="tests:without_schedule", + ) + async def _without_schedule() -> None: + return None + + @broker.task(task_name="tests:invalid_schedule", schedule={}) + async def _invalid_schedule() -> None: + return None + + @broker.task( + task_name="tests:invalid_schedule_2", + schedule=[ + { + "invalid": "data", + }, + ], + ) + async def _invalid_schedule_2() -> None: + return None + + return broker + + +@pytest.mark.integration +async def test_when_labels_contain_schedules__then_get_schedules_returns_scheduled_tasks( + postgresql_dsn: str, + broker_with_scheduled_tasks: PSQLPyBroker, +) -> None: + # When + async with _get_schedule_source( + broker_with_scheduled_tasks, + postgresql_dsn, + ) as schedule_source: + await schedule_source.startup() + schedules: list[ScheduledTask] = await schedule_source.get_schedules() + + # Then + assert len(schedules) == 3 + assert {item.cron for item in schedules} == {"*/10 * * * *", "0 1 * * *", None} + assert {item.cron_offset for item in schedules} == {None, "Europe/Berlin", "PT1H"} + assert {item.task_name for item in schedules} == { + "tests:one_schedule", + "tests:two_schedules", + } + assert {item.time for item in schedules} == {datetime(2024, 1, 1, 12, 0, 0), None} + assert all(item.schedule_id is not None for item in schedules) + + +@pytest.mark.integration +async def test_when_call_add_schedule__then_schedule_creates( + postgresql_dsn: str, + broker_with_scheduled_tasks: PSQLPyBroker, +) -> None: + # Given + new_schedule = ScheduledTaskFactory.build( + task_name="tests:added_schedule", + cron="*/5 * * * *", + interval=None, + ) + async with _get_schedule_source( + broker_with_scheduled_tasks, + postgresql_dsn, + ) as schedule_source: + await schedule_source.startup() + + # When + await schedule_source.add_schedule(new_schedule) + + # Then + schedules: list[ScheduledTask] = await schedule_source.get_schedules() + assert len(schedules) == 4 + added_schedule = None + for task in schedules: + if task.task_name == "tests:added_schedule": + added_schedule = task + break + assert added_schedule is not None + + +@pytest.mark.integration +async def test_when_call_delete_schedule__then_schedule_deleted( + postgresql_dsn: str, + broker_with_scheduled_tasks: PSQLPyBroker, +) -> None: + # Given + async with _get_schedule_source( + broker_with_scheduled_tasks, + postgresql_dsn, + ) as schedule_source: + await schedule_source.startup() + schedules: list[ScheduledTask] = await schedule_source.get_schedules() + schedule_id_to_delete = str(schedules[0].schedule_id) + + # When + await schedule_source.delete_schedule(schedule_id_to_delete) + + # Then + new_schedules: list[ScheduledTask] = await schedule_source.get_schedules() + assert len(new_schedules) == 2 + assert all(task.schedule_id != schedule_id_to_delete for task in new_schedules) diff --git a/uv.lock b/uv.lock index b2984fd..c490714 100644 --- a/uv.lock +++ b/uv.lock @@ -198,6 +198,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/ee/3fd29bf416eb4f1c5579cf12bf393ae954099258abd7bde03c4f9716ef6b/autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840", size = 32483, upload-time = "2024-03-13T03:41:26.969Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "black" version = "25.12.0" @@ -406,6 +415,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "faker" +version = "38.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/27/022d4dbd4c20567b4c294f79a133cc2f05240ea61e0d515ead18c995c249/faker-38.2.0.tar.gz", hash = "sha256:20672803db9c7cb97f9b56c18c54b915b6f1d8991f63d1d673642dc43f5ce7ab", size = 1941469, upload-time = "2025-11-19T16:37:31.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/93/00c94d45f55c336434a15f98d906387e87ce28f9918e4444829a8fda432d/faker-38.2.0-py3-none-any.whl", hash = "sha256:35fe4a0a79dee0dc4103a6083ee9224941e7d3594811a50e3969e547b0d2ee65", size = 1980505, upload-time = "2025-11-19T16:37:30.208Z" }, +] + [[package]] name = "filelock" version = "3.20.0" @@ -550,6 +571,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" }, + { url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/0ae86792fb212e4384041e0ef8e7bc66f59a54912ce407d26a966ed2914d/greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b", size = 597403, upload-time = "2025-12-04T15:07:10.831Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/d4e73f5dfa888364bbf02efa85616c6714ae7c631c201349782e5b428925/greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082", size = 300740, upload-time = "2025-12-04T14:47:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, + { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + [[package]] name = "identify" version = "2.6.15" @@ -906,6 +982,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polyfactory" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3a/db522ea17e0e8d38f3128889b5b600b3a1d5728ae0724f43a0ed5ed1f82e/polyfactory-3.1.0.tar.gz", hash = "sha256:9061c0a282e0594502576455230fce534f2915042be77715256c1e6bbbf24ac5", size = 344189, upload-time = "2025-11-25T08:10:16.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/7c/535646d75a1c510065169ea65693613c7a6bc64491bea13e7dad4f028ff3/polyfactory-3.1.0-py3-none-any.whl", hash = "sha256:78171232342c25906d542513c9f00ebf41eadec2c67b498490a577024dd7e867", size = 61836, upload-time = "2025-11-25T08:10:14.893Z" }, +] + [[package]] name = "pre-commit" version = "4.5.0" @@ -1311,6 +1400,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -1450,6 +1553,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/70/75b1387d72e2847220441166c5eb4e9846dd753895208c13e6d66523b2d9/sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85", size = 2154148, upload-time = "2025-12-10T20:03:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/7805e02323c49cb9d1ae5cd4913b28c97103079765f520043f914fca4cb3/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4", size = 3233051, upload-time = "2025-12-09T22:06:04.768Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/32ae09139f61bef3de3142e85c47abdee8db9a55af2bb438da54a4549263/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0", size = 3232781, upload-time = "2025-12-09T22:09:54.435Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/bf7b869b6f5585eac34222e1cf4405f4ba8c3b85dd6b1af5d4ce8bca695f/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0", size = 3182096, upload-time = "2025-12-09T22:06:06.169Z" }, + { url = "https://files.pythonhosted.org/packages/21/6a/c219720a241bb8f35c88815ccc27761f5af7fdef04b987b0e8a2c1a6dcaa/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826", size = 3205109, upload-time = "2025-12-09T22:09:55.969Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c4/6ccf31b2bc925d5d95fab403ffd50d20d7c82b858cf1a4855664ca054dce/sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a", size = 2114240, upload-time = "2025-12-09T21:29:54.007Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/a27a31fca07316def418db6f7c70ab14010506616a2decef1906050a0587/sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7", size = 2137615, upload-time = "2025-12-09T21:29:55.85Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, + { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + +[[package]] +name = "sqlalchemy-utils" +version = "0.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/7d/eb9565b6a49426552a5bf5c57e7c239c506dc0e4e5315aec6d1e8241dc7c/sqlalchemy_utils-0.42.1.tar.gz", hash = "sha256:881f9cd9e5044dc8f827bccb0425ce2e55490ce44fc0bb848c55cc8ee44cc02e", size = 130789, upload-time = "2025-12-13T03:14:13.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/25/7400c18c3ee97914cc99c90007795c00a4ec5b60c853b49db7ba24d11179/sqlalchemy_utils-0.42.1-py3-none-any.whl", hash = "sha256:243cfe1b3a1dae3c74118ae633f1d1e0ed8c787387bc33e556e37c990594ac80", size = 91761, upload-time = "2025-12-13T03:14:15.014Z" }, +] + [[package]] name = "taskiq" version = "0.12.1" @@ -1489,16 +1653,18 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "anyio" }, { name = "autoflake" }, { name = "black" }, { name = "mypy" }, + { name = "polyfactory" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "sqlalchemy-utils" }, { name = "wemake-python-styleguide" }, { name = "yesqa" }, ] @@ -1511,10 +1677,13 @@ lint = [ { name = "yesqa" }, ] test = [ + { name = "polyfactory" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-xdist" }, + { name = "sqlalchemy-utils" }, ] [package.metadata] @@ -1525,16 +1694,18 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "anyio", specifier = ">=4.12.0" }, { name = "autoflake", specifier = ">=2.3.1" }, { name = "black", specifier = ">=25.11.0" }, { name = "mypy", specifier = ">=1.19.0" }, + { name = "polyfactory", specifier = ">=3.1.0" }, { name = "pre-commit", specifier = ">=4.5.0" }, { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-env", specifier = ">=1.2.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.14.7" }, + { name = "sqlalchemy-utils", specifier = ">=0.42.1" }, { name = "wemake-python-styleguide", specifier = ">=1.4.0" }, { name = "yesqa", specifier = ">=1.5.0" }, ] @@ -1547,10 +1718,13 @@ lint = [ { name = "yesqa", specifier = ">=1.5.0" }, ] test = [ + { name = "polyfactory", specifier = ">=3.1.0" }, { name = "pytest", specifier = ">=9.0.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-env", specifier = ">=1.2.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "sqlalchemy-utils", specifier = ">=0.42.1" }, ] [[package]] @@ -1632,6 +1806,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "virtualenv" version = "20.35.4"