diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89809dd..08283a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,4 +7,16 @@ repos: - id: ruff args: [ --fix ] # Run the formatter. - - id: ruff-format \ No newline at end of file + - id: ruff-format + - repo: local + hooks: + - id: check-sync-async-method-for-queue + name: Check sync/async method for queue + entry: ./scripts/ci/pre_commit/check_sync_async_method_for_queue.py + files: ^pgmq_sqlalchemy/.*\.py$ + language: python + - id: check-sync-async-method-for-operation + name: Check sync/async method for operation + entry: ./scripts/ci/pre_commit/check_sync_async_method_for_operation.py + files: ^pgmq_sqlalchemy/.*\.py$ + language: python \ No newline at end of file diff --git a/pgmq_sqlalchemy/queue.py b/pgmq_sqlalchemy/queue.py index b55c51a..29fba87 100644 --- a/pgmq_sqlalchemy/queue.py +++ b/pgmq_sqlalchemy/queue.py @@ -1,9 +1,10 @@ -from typing import List, Optional +from typing import List, Optional, TYPE_CHECKING from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.asyncio import create_async_engine + from .schema import Message, QueueMetrics from ._types import ENGINE_TYPE, SESSION_TYPE from ._utils import ( @@ -13,6 +14,10 @@ ) from .operation import PGMQOperation +if TYPE_CHECKING: + from sqlalchemy.orm import Session + from sqlalchemy.ext.asyncio import AsyncSession + class PGMQueue: engine: ENGINE_TYPE = None @@ -102,37 +107,10 @@ def __init__( bind=self.engine, class_=get_session_type(self.engine) ) - def _check_pgmq_ext(self) -> None: - """Check if the pgmq extension exists.""" - if self.is_async: - return self.loop.run_until_complete(self._check_pgmq_ext_async()) - return self._check_pgmq_ext_sync() - - async def _check_pg_partman_ext_async(self) -> None: - """Check if the pg_partman extension exists.""" - async with self.session_maker() as session: - await PGMQOperation.check_pg_partman_ext_async(session=session, commit=True) - - def _check_pg_partman_ext_sync(self) -> None: - """Check if the pg_partman extension exists.""" - with self.session_maker() as session: - PGMQOperation.check_pg_partman_ext(session=session, commit=True) - - def _check_pg_partman_ext(self) -> None: - """Check if the pg_partman extension exists.""" - if self.is_pg_partman_ext_checked: - return - self.is_pg_partman_ext_checked - - if self.is_async: - return self.loop.run_until_complete(self._check_pg_partman_ext_async()) - return self._check_pg_partman_ext_sync() - def _execute_operation( self, op_sync, - op_async, - session: Optional[SESSION_TYPE], + session: Optional["Session"], commit: bool, *args, **kwargs, @@ -141,7 +119,6 @@ def _execute_operation( Args: op_sync: The synchronous operation function from PGMQOperation - op_async: The asynchronous operation function from PGMQOperation session: Optional session to use (if None, creates a new one) commit: Whether to commit the transaction *args: Positional arguments to pass to the operation @@ -150,23 +127,36 @@ def _execute_operation( Returns: The result from the operation """ - if self.is_async: - if session is None: - - async def _run(): - async with self.session_maker() as s: - return await op_async(*args, session=s, commit=commit, **kwargs) - - return self.loop.run_until_complete(_run()) - return self.loop.run_until_complete( - op_async(*args, session=session, commit=commit, **kwargs) - ) - if session is None: with self.session_maker() as s: return op_sync(*args, session=s, commit=commit, **kwargs) return op_sync(*args, session=session, commit=commit, **kwargs) + async def _execute_async_operation( + self, + op_async, + session: Optional["AsyncSession"], + commit: bool, + *args, + **kwargs, + ): + """Helper method to execute sync or async operations with session management. + + Args: + op_async: The asynchronous operation function from PGMQOperation + session: Optional session to use (if None, creates a new one) + commit: Whether to commit the transaction + *args: Positional arguments to pass to the operation + **kwargs: Keyword arguments to pass to the operation + + Returns: + The result from the operation + """ + if session is None: + async with self.session_maker() as s: + return await op_async(*args, session=s, commit=commit, **kwargs) + return await op_async(*args, session=session, commit=commit, **kwargs) + def create_queue( self, queue_name: str, @@ -193,6 +183,37 @@ def create_queue( """ return self._execute_operation( PGMQOperation.create_queue, + session, + commit, + queue_name, + unlogged, + ) + + async def create_queue_async( + self, + queue_name: str, + unlogged: bool = False, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> None: + """ + .. _unlogged_table: https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED + .. |unlogged_table| replace:: **UNLOGGED TABLE** + + **Create a new queue.** + + * if ``unlogged`` is ``True``, the queue will be created as an |unlogged_table|_ . + * ``queue_name`` must be **less than 48 characters**. + + .. code-block:: python + + await pgmq_client.create_queue_async('my_queue') + # or unlogged table queue + await pgmq_client.create_queue_async('my_queue', unlogged=True) + + """ + return await self._execute_async_operation( PGMQOperation.create_queue_async, session, commit, @@ -242,10 +263,66 @@ def create_partitioned_queue( """ # check if the pg_partman extension exists before creating a partitioned queue at runtime - self._check_pg_partman_ext() + self._execute_operation( + PGMQOperation.check_pg_partman_ext, session=session, commit=False + ) return self._execute_operation( PGMQOperation.create_partitioned_queue, + session, + commit, + queue_name, + str(partition_interval), + str(retention_interval), + ) + + async def create_partitioned_queue_async( + self, + queue_name: str, + partition_interval: int = 10000, + retention_interval: int = 100000, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> None: + """Create a new **partitioned** queue. + + .. _pgmq_partitioned_queue: https://github.com/tembo-io/pgmq?tab=readme-ov-file#partitioned-queues + .. |pgmq_partitioned_queue| replace:: **PGMQ: Partitioned Queues** + + .. code-block:: python + + # Numeric partitioning (by msg_id) + await pgmq_client.create_partitioned_queue_async('my_partitioned_queue', partition_interval=10000, retention_interval=100000) + + # Time-based partitioning (by enqueued_at) + await pgmq_client.create_partitioned_queue_async('my_time_queue', partition_interval='1 day', retention_interval='7 days') + + Args: + queue_name (str): The name of the queue, should be less than 48 characters. + partition_interval (Union[int, str]): For numeric partitioning, the number of messages per partition. + For time-based partitioning, a PostgreSQL interval string (e.g., '1 day', '1 hour'). + retention_interval (Union[int, str]): For numeric partitioning, messages with msg_id less than max(msg_id) - retention_interval will be dropped. + For time-based partitioning, a PostgreSQL interval string (e.g., '7 days'). + + .. note:: + | Supports both **numeric** (by ``msg_id``) and **time-based** (by ``enqueued_at``) partitioning. + | For time-based partitioning, use interval strings like '1 day', '1 hour', '7 days', etc. + | For numeric partitioning, use integer values. + + .. important:: + | You must make sure that the ``pg_partman`` extension already **installed** in the Postgres. + | ``pgmq-sqlalchemy`` will **auto create** the ``pg_partman`` extension if it does not exist in the Postgres. + | For more details about ``pgmq`` with ``pg_partman``, checkout the |pgmq_partitioned_queue|_. + + + """ + # check if the pg_partman extension exists before creating a partitioned queue at runtime + await self._execute_async_operation( + PGMQOperation.check_pg_partman_ext_async, session=session, commit=False + ) + + return await self._execute_async_operation( PGMQOperation.create_partitioned_queue_async, session, commit, @@ -266,6 +343,22 @@ def validate_queue_name( """ return self._execute_operation( PGMQOperation.validate_queue_name, + session, + commit, + queue_name, + ) + + async def validate_queue_name_async( + self, + queue_name: str, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> None: + """ + * Will raise an error if the ``queue_name`` is more than 48 characters. + """ + return await self._execute_async_operation( PGMQOperation.validate_queue_name_async, session, commit, @@ -299,10 +392,50 @@ def drop_queue( """ # check if the pg_partman extension exists before dropping a partitioned queue at runtime if partitioned: - self._check_pg_partman_ext() + self._execute_operation( + PGMQOperation.check_pg_partman_ext, session=session, commit=False + ) return self._execute_operation( PGMQOperation.drop_queue, + session, + commit, + queue, + partitioned, + ) + + async def drop_queue_async( + self, + queue: str, + partitioned: bool = False, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> bool: + """Drop a queue. + + .. _drop_queue_method: ref:`pgmq_sqlalchemy.PGMQueue.drop_queue` + .. |drop_queue_method| replace:: :py:meth:`~pgmq_sqlalchemy.PGMQueue.drop_queue` + + .. code-block:: python + + await pgmq_client.drop_queue_async('my_queue') + # for partitioned queue + await pgmq_client.drop_queue_async('my_partitioned_queue', partitioned=True) + + .. warning:: + | All messages and queue itself will be deleted. (``pgmq.q_`` table) + | **Archived tables** (``pgmq.a_`` table **will be dropped as well. )** + | + | See |archive_method|_ for more details. + """ + # check if the pg_partman extension exists before dropping a partitioned queue at runtime + if partitioned: + await self._execute_async_operation( + PGMQOperation.check_pg_partman_ext_async, session=session, commit=False + ) + + return await self._execute_async_operation( PGMQOperation.drop_queue_async, session, commit, @@ -325,6 +458,24 @@ def list_queues( """ return self._execute_operation( PGMQOperation.list_queues, + session, + commit, + ) + + async def list_queues_async( + self, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> List[str]: + """List all queues. + + .. code-block:: python + + queue_list = await pgmq_client.list_queues_async() + print(queue_list) + """ + return await self._execute_async_operation( PGMQOperation.list_queues_async, session, commit, @@ -359,6 +510,41 @@ def send( """ return self._execute_operation( PGMQOperation.send, + session, + commit, + queue_name, + message, + delay, + ) + + async def send_async( + self, + queue_name: str, + message: dict, + delay: int = 0, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> int: + """Send a message to a queue. + + .. code-block:: python + + msg_id = await pgmq_client.send_async('my_queue', {'key': 'value', 'key2': 'value2'}) + print(msg_id) + + Example with delay: + + .. code-block:: python + + msg_id = await pgmq_client.send_async('my_queue', {'key': 'value', 'key2': 'value2'}, delay=10) + msg = await pgmq_client.read_async('my_queue') + assert msg is None + await asyncio.sleep(10) + msg = await pgmq_client.read_async('my_queue') + assert msg is not None + """ + return await self._execute_async_operation( PGMQOperation.send_async, session, commit, @@ -390,6 +576,35 @@ def send_batch( """ return self._execute_operation( PGMQOperation.send_batch, + session, + commit, + queue_name, + messages, + delay, + ) + + async def send_batch_async( + self, + queue_name: str, + messages: List[dict], + delay: int = 0, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> List[int]: + """ + Send a batch of messages to a queue. + + .. code-block:: python + + msgs = [{'key': 'value', 'key2': 'value2'}, {'key': 'value', 'key2': 'value2'}] + msg_ids = await pgmq_client.send_batch_async('my_queue', msgs) + print(msg_ids) + # send with delay + msg_ids = await pgmq_client.send_batch_async('my_queue', msgs, delay=10) + + """ + return await self._execute_async_operation( PGMQOperation.send_batch_async, session, commit, @@ -470,36 +685,113 @@ def read( return self._execute_operation( PGMQOperation.read, - PGMQOperation.read_async, session, commit, queue_name, vt, ) - def read_batch( + async def read_async( self, queue_name: str, - batch_size: int = 1, vt: Optional[int] = None, *, session: Optional[SESSION_TYPE] = None, commit: bool = True, - ) -> Optional[List[Message]]: + ) -> Optional[Message]: """ - | Read a batch of messages from the queue. - | Usage: + .. _for_update_skip_locked: https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE + .. |for_update_skip_locked| replace:: **FOR UPDATE SKIP LOCKED** + + .. _read_method: ref:`pgmq_sqlalchemy.PGMQueue.read` + .. |read_method| replace:: :py:meth:`~pgmq_sqlalchemy.PGMQueue.read` + + Read a message from the queue. Returns: - List of |schema_message_class|_ or ``None`` if the queue is empty. + |schema_message_class|_ or ``None`` if the queue is empty. + + .. note:: + | ``PGMQ`` use |for_update_skip_locked|_ lock to make sure **a message is only read by one consumer**. + | See the `pgmq.read `_ function for more details. + | + | For **consumer retries mechanism** (e.g. mark a message as failed after a certain number of retries) can be implemented by using the ``read_ct`` field in the |schema_message_class|_ object. + + + .. important:: + | ``vt`` is the **visibility timeout** in seconds. + | When a message is read from the queue, it will be invisible to other consumers for the duration of the ``vt``. + + Usage: .. code-block:: python from pgmq_sqlalchemy.schema import Message - msgs:List[Message] = pgmq_client.read_batch('my_queue', batch_size=10) - # with vt - msgs:List[Message] = pgmq_client.read_batch('my_queue', batch_size=10, vt=10) + msg:Message = await pgmq_client.read_async('my_queue') + print(msg.msg_id) + print(msg.message) + print(msg.read_ct) # read count, how many times the message has been read + + Example with ``vt``: + + .. code-block:: python + + # assert `read_vt_demo` is empty + await pgmq_client.send_async('read_vt_demo', {'key': 'value', 'key2': 'value2'}) + msg = await pgmq_client.read_async('read_vt_demo', vt=10) + assert msg is not None + + # try to read immediately + msg = await pgmq_client.read_async('read_vt_demo') + assert msg is None # will return None because the message is still invisible + + # try to read after 5 seconds + await asyncio.sleep(5) + msg = await pgmq_client.read_async('read_vt_demo') + assert msg is None # still invisible after 5 seconds + + # try to read after 11 seconds + await asyncio.sleep(6) + msg = await pgmq_client.read_async('read_vt_demo') + assert msg is not None # the message is visible after 10 seconds + + + """ + if vt is None: + vt = self.vt + + return await self._execute_async_operation( + PGMQOperation.read_async, + session, + commit, + queue_name, + vt, + ) + + def read_batch( + self, + queue_name: str, + batch_size: int = 1, + vt: Optional[int] = None, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> Optional[List[Message]]: + """ + | Read a batch of messages from the queue. + | Usage: + + Returns: + List of |schema_message_class|_ or ``None`` if the queue is empty. + + .. code-block:: python + + from pgmq_sqlalchemy.schema import Message + + msgs:List[Message] = pgmq_client.read_batch('my_queue', batch_size=10) + # with vt + msgs:List[Message] = pgmq_client.read_batch('my_queue', batch_size=10, vt=10) """ if vt is None: @@ -507,6 +799,42 @@ def read_batch( return self._execute_operation( PGMQOperation.read_batch, + session, + commit, + queue_name, + vt, + batch_size, + ) + + async def read_batch_async( + self, + queue_name: str, + batch_size: int = 1, + vt: Optional[int] = None, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> Optional[List[Message]]: + """ + | Read a batch of messages from the queue. + | Usage: + + Returns: + List of |schema_message_class|_ or ``None`` if the queue is empty. + + .. code-block:: python + + from pgmq_sqlalchemy.schema import Message + + msgs:List[Message] = await pgmq_client.read_batch_async('my_queue', batch_size=10) + # with vt + msgs:List[Message] = await pgmq_client.read_batch_async('my_queue', batch_size=10, vt=10) + + """ + if vt is None: + vt = self.vt + + return await self._execute_async_operation( PGMQOperation.read_batch_async, session, commit, @@ -579,6 +907,78 @@ def read_with_poll( return self._execute_operation( PGMQOperation.read_with_poll, + session, + commit, + queue_name, + vt, + qty, + max_poll_seconds, + poll_interval_ms, + ) + + async def read_with_poll_async( + self, + queue_name: str, + vt: Optional[int] = None, + qty: int = 1, + max_poll_seconds: int = 5, + poll_interval_ms: int = 100, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> Optional[List[Message]]: + """ + + .. _read_with_poll_method: ref:`pgmq_sqlalchemy.PGMQueue.read_with_poll` + .. |read_with_poll_method| replace:: :py:meth:`~pgmq_sqlalchemy.PGMQueue.read_with_poll` + + + | Read messages from a queue with long-polling. + | + | When the queue is empty, the function block at most ``max_poll_seconds`` seconds. + | During the polling, the function will check the queue every ``poll_interval_ms`` milliseconds, until the queue has ``qty`` messages. + + Args: + queue_name (str): The name of the queue. + vt (Optional[int]): The visibility timeout in seconds. + qty (int): The number of messages to read. + max_poll_seconds (int): The maximum number of seconds to poll. + poll_interval_ms (int): The interval in milliseconds to poll. + + Returns: + List of |schema_message_class|_ or ``None`` if the queue is empty. + + Usage: + + .. code-block:: python + + msg_id = await pgmq_client.send_async('my_queue', {'key': 'value'}, delay=6) + + # the following code will block for 5 seconds + msgs = await pgmq_client.read_with_poll_async('my_queue', qty=1, max_poll_seconds=5, poll_interval_ms=100) + assert msgs is None + + # try read_with_poll again + # the following code will only block for 1 second + msgs = await pgmq_client.read_with_poll_async('my_queue', qty=1, max_poll_seconds=5, poll_interval_ms=100) + assert msgs is not None + + Another example: + + .. code-block:: python + + msg = {'key': 'value'} + msg_ids = await pgmq_client.send_batch_async('my_queue', [msg, msg, msg, msg], delay=3) + + # the following code will block for 3 seconds + msgs = await pgmq_client.read_with_poll_async('my_queue', qty=3, max_poll_seconds=5, poll_interval_ms=100) + assert len(msgs) == 3 # will read at most 3 messages (qty=3) + + """ + if vt is None: + vt = self.vt + + return await self._execute_async_operation( PGMQOperation.read_with_poll_async, session, commit, @@ -657,6 +1057,80 @@ def consumer_with_backoff_retry(pgmq_client: PGMQueue, queue_name: str): return self._execute_operation( PGMQOperation.set_vt, + session, + commit, + queue_name, + msg_id, + vt, + ) + + async def set_vt_async( + self, + queue_name: str, + msg_id: int, + vt: int, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> Optional[Message]: + """ + .. _set_vt_method: ref:`pgmq_sqlalchemy.PGMQueue.set_vt` + .. |set_vt_method| replace:: :py:meth:`~pgmq_sqlalchemy.PGMQueue.set_vt` + + Set the visibility timeout for a message. + + Args: + queue_name (str): The name of the queue. + msg_id (int): The message id. + vt (int): The visibility timeout in seconds. + + Returns: + |schema_message_class|_ or ``None`` if the message does not exist. + + Usage: + + .. code-block:: python + + msg_id = await pgmq_client.send_async('my_queue', {'key': 'value'}, delay=10) + msg = await pgmq_client.read_async('my_queue') + assert msg is not None + msg = await pgmq_client.set_vt_async('my_queue', msg.msg_id, 10) + assert msg is not None + + .. tip:: + | |read_method|_ and |set_vt_method|_ can be used together to implement **exponential backoff** mechanism. + | `ref: Exponential Backoff And Jitter `_. + | **For example:** + + .. code-block:: python + + from pgmq_sqlalchemy import PGMQueue + from pgmq_sqlalchemy.schema import Message + + def _exp_backoff_retry(msg: Message)->int: + # exponential backoff retry + if msg.read_ct < 5: + return 2 ** msg.read_ct + return 2 ** 5 + + def consumer_with_backoff_retry(pgmq_client: PGMQueue, queue_name: str): + msg = await pgmq_client.read_async( + queue_name=queue_name, + vt=1000, # set vt to 1000 seconds temporarily + ) + if msg is None: + return + + # set exponential backoff retry + await pgmq_client.set_vt_async( + queue_name=query_name, + msg_id=msg.msg_id, + vt=_exp_backoff_retry(msg) + ) + + """ + + return await self._execute_async_operation( PGMQOperation.set_vt_async, session, commit, @@ -684,6 +1158,29 @@ def pop( """ return self._execute_operation( PGMQOperation.pop, + session, + commit, + queue_name, + ) + + async def pop_async( + self, + queue_name: str, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> Optional[Message]: + """ + Reads a single message from a queue and deletes it upon read. + + .. code-block:: python + + msg = await pgmq_client.pop_async('my_queue') + print(msg.msg_id) + print(msg.message) + + """ + return await self._execute_async_operation( PGMQOperation.pop_async, session, commit, @@ -717,6 +1214,38 @@ def delete( """ return self._execute_operation( PGMQOperation.delete, + session, + commit, + queue_name, + msg_id, + ) + + async def delete_async( + self, + queue_name: str, + msg_id: int, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> bool: + """ + Delete a message from the queue. + + .. _delete_method: ref:`pgmq_sqlalchemy.PGMQueue.delete` + .. |delete_method| replace:: :py:meth:`~pgmq_sqlalchemy.PGMQueue.delete` + + * Raises an error if the ``queue_name`` does not exist. + * Returns ``True`` if the message is deleted successfully. + * If the message does not exist, returns ``False``. + + .. code-block:: python + + msg_id = await pgmq_client.send_async('my_queue', {'key': 'value'}) + assert await pgmq_client.delete_async('my_queue', msg_id) + assert not await pgmq_client.delete_async('my_queue', msg_id) + + """ + return await self._execute_async_operation( PGMQOperation.delete_async, session, commit, @@ -750,6 +1279,37 @@ def delete_batch( """ return self._execute_operation( PGMQOperation.delete_batch, + session, + commit, + queue_name, + msg_ids, + ) + + async def delete_batch_async( + self, + queue_name: str, + msg_ids: List[int], + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> List[int]: + """ + Delete a batch of messages from the queue. + + .. _delete_batch_method: ref:`pgmq_sqlalchemy.PGMQueue.delete_batch` + .. |delete_batch_method| replace:: :py:meth:`~pgmq_sqlalchemy.PGMQueue.delete_batch` + + .. note:: + | Instead of return `bool` like |delete_method|_, + | |delete_batch_method|_ will return a list of ``msg_id`` that are successfully deleted. + + .. code-block:: python + + msg_ids = await pgmq_client.send_batch_async('my_queue', [{'key': 'value'}, {'key': 'value'}]) + assert await pgmq_client.delete_batch_async('my_queue', msg_ids) == msg_ids + + """ + return await self._execute_async_operation( PGMQOperation.delete_batch_async, session, commit, @@ -787,6 +1347,41 @@ def archive( """ return self._execute_operation( PGMQOperation.archive, + session, + commit, + queue_name, + msg_id, + ) + + async def archive_async( + self, + queue_name: str, + msg_id: int, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> bool: + """ + Archive a message from a queue. + + .. _archive_method: ref:`pgmq_sqlalchemy.PGMQueue.archive` + .. |archive_method| replace:: :py:meth:`~pgmq_sqlalchemy.PGMQueue.archive` + + + * Message will be deleted from the queue and moved to the archive table. + * Will be deleted from ``pgmq.q_`` and be inserted into the ``pgmq.a_`` table. + * raises an error if the ``queue_name`` does not exist. + * returns ``True`` if the message is archived successfully. + + .. code-block:: python + + msg_id = await pgmq_client.send_async('my_queue', {'key': 'value'}) + assert await pgmq_client.archive_async('my_queue', msg_id) + # since the message is archived, queue will be empty + assert await pgmq_client.read_async('my_queue') is None + + """ + return await self._execute_async_operation( PGMQOperation.archive_async, session, commit, @@ -817,6 +1412,34 @@ def archive_batch( """ return self._execute_operation( PGMQOperation.archive_batch, + session, + commit, + queue_name, + msg_ids, + ) + + async def archive_batch_async( + self, + queue_name: str, + msg_ids: List[int], + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> List[int]: + """ + Archive multiple messages from a queue. + + * Messages will be deleted from the queue and moved to the archive table. + * Returns a list of ``msg_id`` that are successfully archived. + + .. code-block:: python + + msg_ids = await pgmq_client.send_batch_async('my_queue', [{'key': 'value'}, {'key': 'value'}]) + assert await pgmq_client.archive_batch_async('my_queue', msg_ids) == msg_ids + assert await pgmq_client.read_async('my_queue') is None + + """ + return await self._execute_async_operation( PGMQOperation.archive_batch_async, session, commit, @@ -844,6 +1467,30 @@ def purge( """ return self._execute_operation( PGMQOperation.purge, + session, + commit, + queue_name, + ) + + async def purge_async( + self, + queue_name: str, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> int: + """ + * Delete all messages from a queue, return the number of messages deleted. + * Archive tables will **not** be affected. + + .. code-block:: python + + msg_ids = await pgmq_client.send_batch_async('my_queue', [{'key': 'value'}, {'key': 'value'}]) + assert await pgmq_client.purge_async('my_queue') == 2 + assert await pgmq_client.read_async('my_queue') is None + + """ + return await self._execute_async_operation( PGMQOperation.purge_async, session, commit, @@ -877,6 +1524,37 @@ def metrics( """ return self._execute_operation( PGMQOperation.metrics, + session, + commit, + queue_name, + ) + + async def metrics_async( + self, + queue_name: str, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> Optional[QueueMetrics]: + """ + Get metrics for a queue. + + Returns: + |schema_queue_metrics_class|_ or ``None`` if the queue does not exist. + + Usage: + + .. code-block:: python + + from pgmq_sqlalchemy.schema import QueueMetrics + + metrics:QueueMetrics = await pgmq_client.metrics_async('my_queue') + print(metrics.queue_name) + print(metrics.queue_length) + print(metrics.queue_length) + + """ + return await self._execute_async_operation( PGMQOperation.metrics_async, session, commit, @@ -924,6 +1602,50 @@ def metrics_all( """ return self._execute_operation( PGMQOperation.metrics_all, + session, + commit, + ) + + async def metrics_all_async( + self, + *, + session: Optional[SESSION_TYPE] = None, + commit: bool = True, + ) -> Optional[List[QueueMetrics]]: + """ + + .. _read_committed_isolation_level: https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED + .. |read_committed_isolation_level| replace:: **READ COMMITTED** + + .. _metrics_all_method: ref:`pgmq_sqlalchemy.PGMQueue.metrics_all` + .. |metrics_all_method| replace:: :py:meth:`~pgmq_sqlalchemy.PGMQueue.metrics_all` + + Get metrics for all queues. + + Returns: + List of |schema_queue_metrics_class|_ or ``None`` if there are no queues. + + Usage: + + .. code-block:: python + + from pgmq_sqlalchemy.schema import QueueMetrics + + metrics:List[QueueMetrics] = await pgmq_client.metrics_all_async() + for m in metrics: + print(m.queue_name) + print(m.queue_length) + print(m.queue_length) + + .. warning:: + | You should use a **distributed lock** to avoid **race conditions** when calling |metrics_all_method|_ in **concurrent** |drop_queue_method|_ **scenarios**. + | + | Since the default PostgreSQL isolation level is |read_committed_isolation_level|_, the queue metrics to be fetched **may not exist** if there are **concurrent** |drop_queue_method|_ **operations**. + | Check the `pgmq.metrics_all `_ function for more details. + + + """ + return await self._execute_async_operation( PGMQOperation.metrics_all_async, session, commit, diff --git a/pyproject.toml b/pyproject.toml index 5cc8557..d9110c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,8 +58,14 @@ dev = [ "sphinx-autobuild>=2024.4.16", "sphinx-rtd-theme>=2.0.0", "sphinx-copybutton>=0.5.2", + # code transformation + "libcst>=1.0.0", ] +[tool.pytest.ini_options] +pythonpath = ["."] +asyncio_mode = "auto" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" \ No newline at end of file diff --git a/scripts/ci/pre_commit/check_sync_async_method_for_operation.py b/scripts/ci/pre_commit/check_sync_async_method_for_operation.py new file mode 100644 index 0000000..dd28531 --- /dev/null +++ b/scripts/ci/pre_commit/check_sync_async_method_for_operation.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.10,<3.11" +# dependencies = [ +# "rich>=13.6.0", +# "libcst>=1.0.0", +# ] +# /// +""" +Script to check for missing async methods in PGMQOperation for per-commit. + +For each public sync method (not starting with _), checks if there's a corresponding +async method with the same name plus '_async' suffix. +""" + +import libcst as cst +import sys +from pathlib import Path + + +sys.path.insert(0, str(Path(__name__).parent.parent.joinpath("scripts").resolve())) + +from scripts_utils.config import OPERATION_FILE # noqa: E402 +from scripts_utils.console import console # noqa: E402 +from scripts_utils.common_ast import ( # noqa: E402 + parse_methods_info_from_target_class, + check_missing_async_methods, +) + + +def main(): + """Main function.""" + + module_tree = cst.parse_module(OPERATION_FILE.read_text()) + _, missing_async = parse_methods_info_from_target_class( + module_tree, target_class="PGMQOperation" + ) + check_missing_async_methods(console, "PGMQOperation", missing_async) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/pre_commit/check_sync_async_method_for_queue.py b/scripts/ci/pre_commit/check_sync_async_method_for_queue.py new file mode 100644 index 0000000..967364b --- /dev/null +++ b/scripts/ci/pre_commit/check_sync_async_method_for_queue.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.10,<3.11" +# dependencies = [ +# "rich>=13.6.0", +# "libcst>=1.0.0", +# ] +# /// +""" +Script to check for missing async methods in PGMQueue for per-commit. + +For each public sync method (not starting with _), checks if there's a corresponding +async method with the same name plus '_async' suffix. +""" + +import libcst as cst +import sys +from pathlib import Path + + +sys.path.insert(0, str(Path(__name__).parent.parent.joinpath("scripts").resolve())) + +from scripts_utils.config import QUEUE_FILE # noqa: E402 +from scripts_utils.console import console # noqa: E402 +from scripts_utils.common_ast import ( # noqa: E402 + parse_methods_info_from_target_class, + check_missing_async_methods, +) # noqa: E402 + + +def main(): + """Main function.""" + + module_tree = cst.parse_module(QUEUE_FILE.read_text()) + _, missing_async = parse_methods_info_from_target_class( + module_tree, target_class="PGMQueue" + ) + check_missing_async_methods(console, "PGMQueue", missing_async) + + +if __name__ == "__main__": + main() diff --git a/scripts/compelete_missing_async_methods_for_operation.py b/scripts/compelete_missing_async_methods_for_operation.py new file mode 100755 index 0000000..5ef5523 --- /dev/null +++ b/scripts/compelete_missing_async_methods_for_operation.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.10,<3.11" +# dependencies = [ +# "rich>=13.6.0", +# "libcst>=1.0.0", +# "ruff>=0.14.10", +# ] +# /// +""" +Script to check for missing async methods in PGMQOperation class and generate them. + +For each public sync method (not starting with _), checks if there's a corresponding +async method with the same name plus '_async' suffix. If missing, generates it. +""" + +import libcst as cst +import sys +from pathlib import Path +import contextlib +import shutil + +import tempfile + + +from scripts_utils.config import OPERATION_FILE, OPERATION_BACKUP_FILE +from scripts_utils.console import console, user_input +from scripts_utils.common_ast import ( + parse_methods_info_from_target_class, + fill_missing_methods_to_class, +) +from scripts_utils.formatting import format_file, compare_file +from scripts_utils.operation_ast import get_async_methods_to_add + + +def main(): + """Main function.""" + + module_tree = cst.parse_module(OPERATION_FILE.read_text()) + sync_methods, missing_async = parse_methods_info_from_target_class( + module_tree, target_class="PGMQOperation" + ) + + if not missing_async: + console.print( + "[bold green]SUCCESS:[/bold green] All public methods have corresponding async versions!" + ) + sys.exit(0) + + # log all the missing async methods + console.print() + console.print( + f"[bold yellow]WARNING:[/bold yellow] Found {len(missing_async)} missing async methods:", + style="bold", + ) + for method in missing_async: + console.print(f" [yellow]-[/yellow] {method}_async") + console.print() + + # create missing async method from + async_methods_to_add = get_async_methods_to_add(sync_methods, missing_async) + # insert back to class + module_tree = fill_missing_methods_to_class( + module_tree, "PGMQOperation", async_methods_to_add + ) + + # write back to tmp file for comparison + tmp_file = "" + with tempfile.NamedTemporaryFile(mode="w+t", delete=False, suffix=".py") as f: + f.write(module_tree.code) + f.flush() + tmp_file = f.name + console.log(f"Complete missing async methods at {tmp_file}") + + if tmp_file: + max_formatting = 3 + for _ in range(max_formatting): + if format_file(tmp_file): + break + + _, missing_async_for_tmp = parse_methods_info_from_target_class( + cst.parse_module(Path(tmp_file).read_text()), "PGMQOperation" + ) + + if missing_async_for_tmp: + console.log( + f"[error]Still get async methods to add after generating missing async methods in {tmp_file}: {missing_async_for_tmp}[/]" + ) + else: + console.log("[success]All missing async methods are generated[/]") + + # compare existed operation.py and tmp.py + with contextlib.suppress(Exception): + compare_file(OPERATION_FILE, tmp_file) + + # ask whether to apply the change + if user_input(f"Do you want to apply change to {OPERATION_FILE}"): + console.log(f"Backup existed {OPERATION_FILE} at {OPERATION_BACKUP_FILE}") + shutil.copy(OPERATION_FILE, OPERATION_BACKUP_FILE) + shutil.copy(tmp_file, OPERATION_FILE) + console.log("Add missing async methods successfully") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/compelete_missing_async_methods_for_queue.py b/scripts/compelete_missing_async_methods_for_queue.py new file mode 100644 index 0000000..5616438 --- /dev/null +++ b/scripts/compelete_missing_async_methods_for_queue.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.10,<3.11" +# dependencies = [ +# "rich>=13.6.0", +# "libcst>=1.0.0", +# "ruff>=0.14.10", +# ] +# /// +""" +Script to check for missing async methods in PGMQueue class and generate them. + +For each public sync method (not starting with _), checks if there's a corresponding +async method with the same name plus '_async' suffix. If missing, generates it. +""" + +import libcst as cst +import sys +from pathlib import Path +import contextlib +import shutil + +import tempfile + + +from scripts_utils.config import QUEUE_FILE, QUEUE_BACKUP_FILE +from scripts_utils.console import console, user_input +from scripts_utils.common_ast import ( + parse_methods_info_from_target_class, + fill_missing_methods_to_class, +) +from scripts_utils.formatting import format_file, compare_file +from scripts_utils.queue_ast import get_async_methods_to_add + + +def main(): + """Main function.""" + + module_tree = cst.parse_module(QUEUE_FILE.read_text()) + sync_methods, missing_async = parse_methods_info_from_target_class( + module_tree, target_class="PGMQueue" + ) + + if not missing_async: + console.print( + "[bold green]SUCCESS:[/bold green] All public methods have corresponding async versions!" + ) + sys.exit(0) + + # log all the missing async methods + console.print() + console.print( + f"[bold yellow]WARNING:[/bold yellow] Found {len(missing_async)} missing async methods:", + style="bold", + ) + for method in missing_async: + console.print(f" [yellow]-[/yellow] {method}_async") + console.print() + + # create missing async method from + async_methods_to_add = get_async_methods_to_add(sync_methods, missing_async) + # insert back to class + module_tree = fill_missing_methods_to_class( + module_tree, "PGMQueue", async_methods_to_add + ) + + # write back to tmp file for comparison + tmp_file = "" + with tempfile.NamedTemporaryFile(mode="w+t", delete=False, suffix=".py") as f: + f.write(module_tree.code) + f.flush() + tmp_file = f.name + console.log(f"Complete missing async methods at {tmp_file}") + + if tmp_file: + max_formatting = 3 + for _ in range(max_formatting): + if format_file(tmp_file): + break + + _, missing_async_for_tmp = parse_methods_info_from_target_class( + cst.parse_module(Path(tmp_file).read_text()), "PGMQueue" + ) + + if missing_async_for_tmp: + console.log( + f"[error]Still get async methods to add after generating missing async methods in {tmp_file}: {missing_async_for_tmp}[/]" + ) + else: + console.log("[success]All missing async methods are generated[/]") + + # compare existed queue.py and tmp.py + with contextlib.suppress(Exception): + compare_file(QUEUE_FILE, tmp_file) + + # ask whether to apply the change + if user_input(f"Do you want to apply change to {QUEUE_FILE}"): + console.log(f"Backup existed {QUEUE_FILE} at {QUEUE_BACKUP_FILE}") + shutil.copy(QUEUE_FILE, QUEUE_BACKUP_FILE) + shutil.copy(tmp_file, QUEUE_FILE) + console.log("Add missing async methods successfully") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/compelete_missing_test_for_operation.py b/scripts/compelete_missing_test_for_operation.py new file mode 100755 index 0000000..4f2f2fe --- /dev/null +++ b/scripts/compelete_missing_test_for_operation.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.10,<3.11" +# dependencies = [ +# "rich>=13.6.0", +# "libcst>=1.0.0", +# "ruff>=0.14.10", +# ] +# /// +""" +Script to check for missing async tests in test_operation.py and generate them. + +For each public sync test (test_*_sync), checks if there's a corresponding +async test with _async suffix. If missing, generates it using CST transformations. +""" + +import libcst as cst +import sys +from pathlib import Path +import contextlib +import shutil +import tempfile + + +from scripts_utils.console import console, user_input +from scripts_utils.formatting import format_file, compare_file +from scripts_utils.operation_test_ast import ( + parse_test_functions_from_module, + get_async_tests_to_add, + fill_missing_tests_to_module, +) + + +def main(): + """Main function.""" + + # Define test file path + PROJECT_ROOT = Path(__file__).parent.parent + TEST_FILE = PROJECT_ROOT / "tests" / "test_operation.py" + TEST_BACKUP_FILE = PROJECT_ROOT / "tests" / "test_operation_backup.py" + + if not TEST_FILE.exists(): + console.print(f"[bold red]ERROR:[/bold red] Test file not found: {TEST_FILE}") + sys.exit(1) + + module_tree = cst.parse_module(TEST_FILE.read_text()) + all_tests, missing_async = parse_test_functions_from_module(module_tree) + + if not missing_async: + console.print( + "[bold green]SUCCESS:[/bold green] All sync tests have corresponding async versions!" + ) + sys.exit(0) + + # Log all the missing async tests + console.print() + console.print( + f"[bold yellow]WARNING:[/bold yellow] Found {len(missing_async)} missing async tests:", + style="bold", + ) + for test_name in sorted(missing_async): + async_name = test_name.replace("_sync", "_async") + console.print(f" [yellow]-[/yellow] {async_name}") + console.print() + + # Create missing async tests + async_tests_to_add = get_async_tests_to_add(all_tests, missing_async) + + # Insert back to module + module_tree = fill_missing_tests_to_module(module_tree, async_tests_to_add) + + # Write back to tmp file for comparison + tmp_file = "" + with tempfile.NamedTemporaryFile(mode="w+t", delete=False, suffix=".py") as f: + f.write(module_tree.code) + f.flush() + tmp_file = f.name + console.log(f"Generated missing async tests at {tmp_file}") + + if tmp_file: + max_formatting = 3 + for _ in range(max_formatting): + if format_file(tmp_file): + break + + # Verify that all async tests are now present + _, missing_async_for_tmp = parse_test_functions_from_module( + cst.parse_module(Path(tmp_file).read_text()) + ) + + if missing_async_for_tmp: + console.log( + f"[error]Still have missing async tests after generation in {tmp_file}: {missing_async_for_tmp}[/]" + ) + else: + console.log("[success]All missing async tests are generated[/]") + + # Compare existing test file and tmp file + with contextlib.suppress(Exception): + compare_file(TEST_FILE, tmp_file) + + # Ask whether to apply the change + if user_input(f"Do you want to apply change to {TEST_FILE}"): + console.log(f"Backup existing {TEST_FILE} at {TEST_BACKUP_FILE}") + shutil.copy(TEST_FILE, TEST_BACKUP_FILE) + shutil.copy(tmp_file, TEST_FILE) + console.log("Added missing async tests successfully") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/compelete_missing_test_for_queue.py b/scripts/compelete_missing_test_for_queue.py new file mode 100755 index 0000000..28df39b --- /dev/null +++ b/scripts/compelete_missing_test_for_queue.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.10,<3.11" +# dependencies = [ +# "rich>=13.6.0", +# "libcst>=1.0.0", +# "ruff>=0.14.10", +# ] +# /// +""" +Script to check for missing async test functions in test_queue.py and generate them. + +For each test function (starting with 'test_'), checks if there's a corresponding +async test with the same name plus '_async' suffix. If missing, generates it. +""" + +import libcst as cst +import sys +from pathlib import Path +import contextlib +import shutil + +import tempfile + + +from scripts_utils.config import TEST_QUEUE_FILE, TEST_QUEUE_BACKUP_FILE +from scripts_utils.console import console, user_input +from scripts_utils.queue_test_ast import ( + parse_test_functions_from_module, + get_async_tests_to_add, + fill_missing_tests_to_module, +) +from scripts_utils.formatting import format_file, compare_file + + +def main(): + """Main function.""" + + module_tree = cst.parse_module(TEST_QUEUE_FILE.read_text()) + sync_tests, missing_async = parse_test_functions_from_module(module_tree) + + if not missing_async: + console.print( + "[bold green]SUCCESS:[/bold green] All test functions have corresponding async versions!" + ) + sys.exit(0) + + # log all the missing async tests + console.print() + console.print( + f"[bold yellow]WARNING:[/bold yellow] Found {len(missing_async)} missing async tests:", + style="bold", + ) + for test in missing_async: + console.print(f" [yellow]-[/yellow] {test}_async") + console.print() + + # create missing async tests + async_tests_to_add = get_async_tests_to_add(sync_tests, missing_async) + # insert back to module + module_tree = fill_missing_tests_to_module(module_tree, async_tests_to_add) + + # write back to tmp file for comparison + with tempfile.NamedTemporaryFile(mode="w+t", delete=False, suffix=".py") as f: + f.write(module_tree.code) + f.flush() + tmp_file = f.name + console.log(f"Complete missing async tests at {tmp_file}") + + if tmp_file: + max_formatting = 3 + for _ in range(max_formatting): + if format_file(tmp_file): + break + + _, missing_async_for_tmp = parse_test_functions_from_module( + cst.parse_module(Path(tmp_file).read_text()) + ) + + if missing_async_for_tmp: + console.log( + f"[error]Still have async tests missing after generating in {tmp_file}: {missing_async_for_tmp}[/]" + ) + else: + console.log("[success]All missing async tests are generated[/]") + + # compare existed test_queue.py and tmp.py + with contextlib.suppress(Exception): + compare_file(TEST_QUEUE_FILE, tmp_file) + + # ask whether to apply the change + if user_input(f"Do you want to apply change to {TEST_QUEUE_FILE}"): + console.log(f"Backup existed {TEST_QUEUE_FILE} at {TEST_QUEUE_BACKUP_FILE}") + shutil.copy(TEST_QUEUE_FILE, TEST_QUEUE_BACKUP_FILE) + shutil.copy(tmp_file, TEST_QUEUE_FILE) + console.log("Add missing async tests successfully") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/scripts_utils/__init__.py b/scripts/scripts_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/scripts_utils/common_ast.py b/scripts/scripts_utils/common_ast.py new file mode 100644 index 0000000..81eb55b --- /dev/null +++ b/scripts/scripts_utils/common_ast.py @@ -0,0 +1,147 @@ +from typing import List, Dict, Tuple, Literal, Set, TYPE_CHECKING +import sys + +import libcst as cst + +if TYPE_CHECKING: + from rich.console import Console + + +class MethodInfo: + """Information about a method.""" + + def __init__(self, name: str, node: cst.FunctionDef): + self.name = name + self.node = node + self.is_target = not name.startswith( + "_" + ) # all the public method is our target method for further processing + self.is_async = name.endswith("_async") + self.base_name = name[:-6] if self.is_async else name + + +class ParseTargetClassFunctionsVisitor(cst.CSTVisitor): + """CST visitor to parse functions out of target class name for given module tree""" + + def __init__(self, class_name: str): + self.class_name = class_name + self.methods: List[MethodInfo] = [] + self.is_cur_node_in_target_class = False + + def visit_ClassDef(self, node: cst.ClassDef): + if node.name.value == self.class_name: + self.is_cur_node_in_target_class = True + # Visit children + for stmt in node.body.body: + if isinstance(stmt, cst.FunctionDef): + self.methods.append(MethodInfo(stmt.name.value, stmt)) + self.is_cur_node_in_target_class = False + + +class FillMissingMethodsToClass(cst.CSTTransformer): + """CST Transformer to fill missing async_methods back to target class""" + + def __init__(self, class_name: str, to_add_async_methods: Dict[str, MethodInfo]): + self.class_name = class_name + self.to_add_async_methods = to_add_async_methods + + def leave_ClassDef( + self, original_node: cst.ClassDef, updated_node: cst.ClassDef + ) -> cst.ClassDef: + if updated_node.name.value == self.class_name: + # Get current body statements + body_statements = list(updated_node.body.body) + new_body = [] + + for stmt in body_statements: + new_body.append(stmt) + # If this is a sync function, check if we need to add async version after it + if isinstance(stmt, cst.FunctionDef): + func_name = stmt.name.value + if func_name in self.to_add_async_methods: + # Add the async version right after the sync version + new_body.append(self.to_add_async_methods[func_name].node) + + # Update the class body with new statements + return updated_node.with_changes( + body=updated_node.body.with_changes(body=new_body) + ) + + return updated_node + + +def parse_methods_info_from_target_class( + module_tree: cst.Module, target_class: Literal["PGMQueue", "PGMQOperation"] +) -> Tuple[List[MethodInfo], set[str]]: + """ + Parse methods of target class from give module CST Tree + + Args: + module_tree: cst.Module + target_class: either "PGMQueue" or "PGMQOperation" str + + Returns: + Tuple of sync_methods, missing_async_set + """ + + analyzer = ParseTargetClassFunctionsVisitor(target_class) + module_tree.visit(analyzer) + + # Categorize methods + # We use sync methods as source of truth + async_methods_set = set() + missing_async_set = set() + + for method_info in analyzer.methods: + # skip non target methods + if not method_info.is_target: + continue + + if method_info.is_async: + async_methods_set.add(method_info.base_name) + + # Find missing async methods and generate class with interleaved methods + for method_info in analyzer.methods: + # skip non target methods + if not method_info.is_target: + continue + + if method_info.base_name not in async_methods_set: + missing_async_set.add(method_info.base_name) + + return analyzer.methods, missing_async_set + + +def fill_missing_methods_to_class( + module_tree: cst.Module, + target_class: Literal["PGMQueue", "PGMQOperation"], + to_add_async_methods: Dict[str, MethodInfo], +) -> cst.Module: + transformer = FillMissingMethodsToClass( + class_name=target_class, to_add_async_methods=to_add_async_methods + ) + return module_tree.visit(transformer) + + +def check_missing_async_methods( + console: "Console", + target_class: Literal["PGMQueue", "PGMQOperation"], + missing_async: Set[str], +) -> None: + if not missing_async: + console.print( + f"[bold green]SUCCESS:[/bold green] All public methods have corresponding async versions for {target_class}!" + ) + sys.exit(0) + + # log all the missing async methods + console.print() + console.print( + f"[bold yellow]WARNING:[/bold yellow] Found {len(missing_async)} missing async methods for {target_class}:", + style="bold", + ) + for method in missing_async: + console.print(f" [yellow]-[/yellow] {method}_async") + console.print() + + sys.exit(1) diff --git a/scripts/scripts_utils/config.py b/scripts/scripts_utils/config.py new file mode 100644 index 0000000..7259f0d --- /dev/null +++ b/scripts/scripts_utils/config.py @@ -0,0 +1,11 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent.parent +SOURCE_PATH = PROJECT_ROOT / "pgmq_sqlalchemy" +QUEUE_FILE = SOURCE_PATH / "queue.py" +QUEUE_BACKUP_FILE = SOURCE_PATH / "queue_backup.py" +OPERATION_FILE = SOURCE_PATH / "operation.py" +OPERATION_BACKUP_FILE = SOURCE_PATH / "operation_backup.py" +TESTS_PATH = PROJECT_ROOT / "tests" +TEST_QUEUE_FILE = TESTS_PATH / "test_queue.py" +TEST_QUEUE_BACKUP_FILE = TESTS_PATH / "test_queue_backup.py" diff --git a/scripts/scripts_utils/console.py b/scripts/scripts_utils/console.py new file mode 100644 index 0000000..e86721e --- /dev/null +++ b/scripts/scripts_utils/console.py @@ -0,0 +1,27 @@ +from rich.console import Console +from rich.theme import Theme + + +console = Console( + force_terminal=True, + color_system="standard", + theme=Theme( + { + "success": "green", + "info": "bright_blue", + "warning": "bright_yellow", + "error": "red", + "special": "magenta", + } + ), + width=202, +) + + +def user_input(msg: str) -> bool: + console.log(f"{msg} (y/n)") + s = input() + if len(s) != 1: + return False + + return s.lower() == "y" diff --git a/scripts/scripts_utils/formatting.py b/scripts/scripts_utils/formatting.py new file mode 100644 index 0000000..a67950b --- /dev/null +++ b/scripts/scripts_utils/formatting.py @@ -0,0 +1,24 @@ +import subprocess +import sys +from pathlib import Path + + +sys.path.insert(0, str(Path(__name__).parent.parent.joinpath("scripts").resolve())) + + +def format_file(file_path: str) -> bool: + try: + ruff_stdout = subprocess.check_output(["ruff", "format", file_path]).decode() + except Exception as e: + raise e + + return "unchanged" in ruff_stdout + + +def compare_file(existed_file: str, new_file: str): + try: + subprocess.check_call( + ["git", "difftool", "--tool=vimdiff", "--no-index", existed_file, new_file] + ) + except Exception as e: + raise e diff --git a/scripts/scripts_utils/operation_ast.py b/scripts/scripts_utils/operation_ast.py new file mode 100644 index 0000000..42315fd --- /dev/null +++ b/scripts/scripts_utils/operation_ast.py @@ -0,0 +1,154 @@ +import libcst as cst +import re +import sys +from pathlib import Path +from typing import List, Set, Dict + +sys.path.insert(0, str(Path(__file__).parent.parent.joinpath("scripts").resolve())) + +from scripts_utils.common_ast import MethodInfo # noqa: E402 + + +class AsyncOperationTransformer(cst.CSTTransformer): + """Transform sync PGMQOperation methods to async versions.""" + + def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Transform session.execute() and session.commit() calls to await.""" + # Check if this is a session.execute() or session.commit() call + if isinstance(updated_node.func, cst.Attribute): + if isinstance(updated_node.func.value, cst.Name): + if updated_node.func.value.value == "session": + if updated_node.func.attr.value in ["execute", "commit"]: + # Wrap in await + return cst.Await(expression=updated_node) + + return updated_node + + def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.FunctionDef: + """Transform function to async version.""" + # Transform function to async + new_node = updated_node.with_changes( + asynchronous=cst.Asynchronous(), + name=cst.Name(f"{updated_node.name.value}_async") + ) + + # Update parameters - change Session to AsyncSession + if updated_node.params: + new_params = self._transform_params(updated_node.params) + new_node = new_node.with_changes(params=new_params) + + # Transform docstring if exists + if updated_node.body.body and isinstance(updated_node.body.body[0], cst.SimpleStatementLine): + first_stmt = updated_node.body.body[0] + if first_stmt.body and isinstance(first_stmt.body[0], cst.Expr): + expr = first_stmt.body[0] + if isinstance(expr.value, (cst.SimpleString, cst.ConcatenatedString)): + # Extract docstring value + if isinstance(expr.value, cst.SimpleString): + docstring = expr.value.value + else: + # For concatenated strings, we'll skip transformation for now + docstring = None + + if docstring and len(docstring) >= 2: + # Remove quotes to get actual string content + if len(docstring) >= 6 and (docstring.startswith('"""') or docstring.startswith("'''")): + quote = docstring[:3] + content = docstring[3:-3] + elif len(docstring) >= 2 and (docstring.startswith('"') or docstring.startswith("'")): + quote = docstring[0] + content = docstring[1:-1] + else: + content = docstring + quote = '"""' + + transformed_content = self.transform_docstring(content) + new_docstring = f'{quote}{transformed_content}{quote}' + + # Create new docstring node + new_expr = expr.with_changes(value=cst.SimpleString(new_docstring)) + new_first_stmt = first_stmt.with_changes(body=[new_expr]) + + # Update body with new docstring + new_body = [new_first_stmt] + list(updated_node.body.body[1:]) + new_node = new_node.with_changes( + body=new_node.body.with_changes(body=new_body) + ) + + return new_node + + def _transform_params(self, params: cst.Parameters) -> cst.Parameters: + """Transform parameters - change Session to AsyncSession.""" + new_kwonly_params = [] + + if params.kwonly_params: + for param in params.kwonly_params: + if param.annotation: + # Check if annotation is Session + if isinstance(param.annotation.annotation, cst.Name): + if param.annotation.annotation.value == "Session": + # Replace Session with AsyncSession + new_annotation = param.annotation.with_changes( + annotation=cst.Name("AsyncSession") + ) + new_param = param.with_changes(annotation=new_annotation) + new_kwonly_params.append(new_param) + continue + new_kwonly_params.append(param) + + return params.with_changes(kwonly_params=new_kwonly_params) + + def transform_docstring(self, docstring: str) -> str: + """Transform docstring for async version.""" + # Replace references to sync version with async version + modified = docstring + + # Change method description to indicate it's async + if "asynchronously" not in modified.lower() and "(async)" not in modified.lower(): + # Add async indication at the end of first sentence if not present + modified = re.sub( + r'(^[^.]+\.)', + r'\1 (async)', + modified, + count=1 + ) + # Or if that didn't work, try to add it more explicitly + if "(async)" not in modified: + # Replace session reference + modified = re.sub( + r'SQLAlchemy session\.', + r'Async SQLAlchemy session.', + modified + ) + modified = re.sub( + r'session: SQLAlchemy session', + r'session: Async SQLAlchemy session', + modified + ) + + return modified + + +def transform_to_async_operation( + transformer: AsyncOperationTransformer, method_info: MethodInfo +) -> MethodInfo: + """Transform a sync method to async for PGMQOperation.""" + orig_sync_func_node = method_info.node + async_node = orig_sync_func_node.visit(transformer) + + return MethodInfo(f"{method_info.base_name}_async", async_node) + + +def get_async_methods_to_add( + sync_methods: List[MethodInfo], missing_async: Set[str] +) -> Dict[str, MethodInfo]: + """Get async methods to add for missing sync methods.""" + transformer = AsyncOperationTransformer() + async_methods: Dict[str, MethodInfo] = {} + for method_info in sync_methods: + if method_info.base_name in missing_async: + async_methods[method_info.base_name] = transform_to_async_operation( + transformer, method_info + ) + + return async_methods diff --git a/scripts/scripts_utils/operation_test_ast.py b/scripts/scripts_utils/operation_test_ast.py new file mode 100644 index 0000000..d0e83cd --- /dev/null +++ b/scripts/scripts_utils/operation_test_ast.py @@ -0,0 +1,354 @@ +import libcst as cst +from typing import Dict, Set, List, Tuple +from scripts_utils.common_ast import MethodInfo + + +class AsyncTestTransformer(cst.CSTTransformer): + """Transform sync test functions to async test functions.""" + + def leave_FunctionDef( + self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef + ) -> cst.FunctionDef: + """Transform function to async test.""" + # Change function name from _sync to _async + new_name = updated_node.name.value.replace("_sync", "_async") + + # Add async keyword + new_node = updated_node.with_changes( + asynchronous=cst.Asynchronous(), name=cst.Name(new_name) + ) + + # Transform docstring if exists + if updated_node.body.body and isinstance( + updated_node.body.body[0], cst.SimpleStatementLine + ): + first_stmt = updated_node.body.body[0] + if first_stmt.body and isinstance(first_stmt.body[0], cst.Expr): + expr = first_stmt.body[0] + if isinstance(expr.value, (cst.SimpleString, cst.ConcatenatedString)): + # Extract docstring value + if isinstance(expr.value, cst.SimpleString): + docstring = expr.value.value + else: + # For concatenated strings, skip transformation + docstring = None + + if docstring: + # Remove quotes to get actual string content + if docstring.startswith('"""') or docstring.startswith("'''"): + quote = docstring[:3] + content = docstring[3:-3] + elif docstring.startswith('"') or docstring.startswith("'"): + quote = docstring[0] + content = docstring[1:-1] + else: + content = docstring + quote = '"""' + + transformed_content = self.transform_docstring(content) + new_docstring = f"{quote}{transformed_content}{quote}" + + # Create new docstring node + new_expr = expr.with_changes( + value=cst.SimpleString(new_docstring) + ) + new_first_stmt = first_stmt.with_changes(body=[new_expr]) + + # Update body with new docstring + new_body = [new_first_stmt] + list(updated_node.body.body[1:]) + new_node = new_node.with_changes( + body=new_node.body.with_changes(body=new_body) + ) + + return new_node + + def leave_Param( + self, original_node: cst.Param, updated_node: cst.Param + ) -> cst.Param: + """Transform function parameters to use async fixtures.""" + param_name = updated_node.name.value + + if param_name == "get_session_maker": + return updated_node.with_changes(name=cst.Name("get_async_session_maker")) + if param_name == "db_session": + return updated_node.with_changes(name=cst.Name("async_db_session")) + elif param_name == "pgmq_setup_teardown": + return updated_node.with_changes(name=cst.Name("async_pgmq_setup_teardown")) + elif param_name == "pgmq_partitioned_setup_teardown": + return updated_node.with_changes( + name=cst.Name("async_pgmq_partitioned_setup_teardown") + ) + + return updated_node + + def leave_With(self, original_node: cst.With, updated_node: cst.With) -> cst.With: + """Transform 'with' statements to 'async with'.""" + # Check if this is a session context manager + for item in updated_node.items: + if isinstance(item.item, cst.Call): + if isinstance(item.item.func, cst.Name): + if "session_maker" in item.item.func.value: + # Transform to async with + return updated_node.with_changes( + asynchronous=cst.Asynchronous() + ) + + return updated_node + + def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Transform method calls to add _async suffix and await.""" + # Check if this is a PGMQOperation method call + if isinstance(updated_node.func, cst.Attribute): + if isinstance(updated_node.func.value, cst.Name): + if updated_node.func.value.value == "PGMQOperation": + # Add _async suffix to method name + new_func = updated_node.func.with_changes( + attr=cst.Name(f"{updated_node.func.attr.value}_async") + ) + return updated_node.with_changes(func=new_func) + + # Check if this is a function call + if isinstance(updated_node.func, cst.Name): + if updated_node.func.value == "get_session_maker": + # Replace with get_async_session_maker + return updated_node.with_changes( + func=cst.Name("get_async_session_maker") + ) + elif updated_node.func.value == "check_queue_exists": + # Replace with check_queue_exists_async and update db_session arg + new_call = updated_node.with_changes( + func=cst.Name("check_queue_exists_async") + ) + # Also need to update the db_session argument to async_db_session + if updated_node.args: + new_args = [] + for arg in updated_node.args: + if ( + isinstance(arg.value, cst.Name) + and arg.value.value == "db_session" + ): + new_args.append( + arg.with_changes(value=cst.Name("async_db_session")) + ) + else: + new_args.append(arg) + new_call = new_call.with_changes(args=new_args) + # Wrap in await + return cst.Await(expression=new_call) + + return updated_node + + def leave_Assign( + self, original_node: cst.Assign, updated_node: cst.Assign + ) -> cst.Assign: + """Add await to assignments that call async methods.""" + # Check if the value is a PGMQOperation call + if isinstance(updated_node.value, cst.Call): + if isinstance(updated_node.value.func, cst.Attribute): + if isinstance(updated_node.value.func.value, cst.Name): + if updated_node.value.func.value.value == "PGMQOperation": + # Wrap in await + return updated_node.with_changes( + value=cst.Await(expression=updated_node.value) + ) + # Check if this is a session method call (session.commit, session.rollback, etc.) + elif updated_node.value.func.value.value == "session": + # Wrap in await + return updated_node.with_changes( + value=cst.Await(expression=updated_node.value) + ) + + return updated_node + + def leave_Expr(self, original_node: cst.Expr, updated_node: cst.Expr) -> cst.Expr: + """Add await to expression statements that call async methods.""" + # Check if this is a PGMQOperation call (not in assignment) + if isinstance(updated_node.value, cst.Call): + if isinstance(updated_node.value.func, cst.Attribute): + if isinstance(updated_node.value.func.value, cst.Name): + if updated_node.value.func.value.value == "PGMQOperation": + # Wrap in await + return updated_node.with_changes( + value=cst.Await(expression=updated_node.value) + ) + # Check if this is a session method call (session.commit, session.rollback, etc.) + elif updated_node.value.func.value.value == "session": + # Wrap in await + return updated_node.with_changes( + value=cst.Await(expression=updated_node.value) + ) + + return updated_node + + def transform_docstring(self, docstring: str) -> str: + """Transform docstring for async version.""" + # Replace 'synchronously' with 'asynchronously' + modified = docstring.replace( + "using PGMQOperation.", "using PGMQOperation asynchronously." + ) + + # Add 'asynchronously' before the period if not already present + if "asynchronously" not in modified and not modified.endswith( + "asynchronously." + ): + modified = modified.rstrip(".") + if modified and not modified.endswith("asynchronously"): + modified += " asynchronously." + + return modified + + +class TestFunctionVisitor(cst.CSTVisitor): + """Visitor to collect test functions from a module.""" + + def __init__(self): + self.test_functions: List[MethodInfo] = [] + + def visit_FunctionDef(self, node: cst.FunctionDef) -> None: + """Visit function definitions and collect test functions.""" + func_name = node.name.value + if func_name.startswith("test_"): + # Determine if it's async or sync + is_async = func_name.endswith("_async") + base_name = func_name[:-6] if is_async else func_name + + method_info = MethodInfo(func_name, node) + method_info.is_target = True + method_info.is_async = is_async + method_info.base_name = base_name + + self.test_functions.append(method_info) + + +class FillMissingTestsTransformer(cst.CSTTransformer): + """Transformer to add missing async tests after their sync counterparts.""" + + def __init__(self, to_add_async_tests: Dict[str, MethodInfo]): + self.to_add_async_tests = to_add_async_tests + self.added_decorators = False + + def leave_Module( + self, original_node: cst.Module, updated_node: cst.Module + ) -> cst.Module: + """Transform the module to add missing async tests.""" + new_body = [] + + for stmt in updated_node.body: + new_body.append(stmt) + + # If this is a sync test function, check if we need to add async version + if isinstance(stmt, cst.FunctionDef): + func_name = stmt.name.value + if func_name in self.to_add_async_tests: + # Add decorator before async test + decorator = cst.Decorator( + decorator=cst.Attribute( + value=cst.Attribute( + value=cst.Name("pytest"), attr=cst.Name("mark") + ), + attr=cst.Name("asyncio"), + ) + ) + + async_test = self.to_add_async_tests[func_name].node + + # Add decorator to async test + if async_test.decorators: + decorated_async = async_test.with_changes( + decorators=[decorator] + list(async_test.decorators) + ) + else: + decorated_async = async_test.with_changes( + decorators=[decorator] + ) + + # Add empty line before async test for readability + new_body.append( + cst.EmptyLine(indent=False, whitespace=cst.SimpleWhitespace("")) + ) + new_body.append( + cst.EmptyLine(indent=False, whitespace=cst.SimpleWhitespace("")) + ) + new_body.append(decorated_async) + + return updated_node.with_changes(body=new_body) + + +def parse_test_functions_from_module( + module_tree: cst.Module, +) -> Tuple[List[MethodInfo], Set[str]]: + """ + Parse test functions from module. + + Returns: + Tuple of (all_test_functions, missing_async_test_names) + """ + visitor = TestFunctionVisitor() + module_tree.visit(visitor) + + # Categorize tests + async_tests_set = set() + missing_async_set = set() + + for test_info in visitor.test_functions: + if not test_info.is_target: + continue + + if test_info.is_async: + # Extract base name without _async suffix + base_name = test_info.name.replace("_async", "") + async_tests_set.add(base_name) + + # Find missing async tests + for test_info in visitor.test_functions: + if not test_info.is_target: + continue + + # Check if this is a sync test + if test_info.name.endswith("_sync"): + # Get base name without _sync suffix + base_name_without_sync = test_info.name.replace("_sync", "") + # Check if async version exists + if base_name_without_sync not in async_tests_set: + missing_async_set.add(test_info.name) # Store full sync name + + return visitor.test_functions, missing_async_set + + +def transform_test_to_async(test_info: MethodInfo) -> MethodInfo: + """Transform a sync test function to async.""" + transformer = AsyncTestTransformer() + async_node = test_info.node.visit(transformer) + + new_name = test_info.name.replace("_sync", "_async") + return MethodInfo(new_name, async_node) + + +def get_async_tests_to_add( + all_tests: List[MethodInfo], missing_async: Set[str] +) -> Dict[str, MethodInfo]: + """ + Generate async tests for missing ones. + + Args: + all_tests: All test functions found + missing_async: Set of sync test names that need async versions + + Returns: + Dictionary mapping sync test name to async MethodInfo + """ + async_tests: Dict[str, MethodInfo] = {} + + for test_info in all_tests: + if test_info.name in missing_async: + async_tests[test_info.name] = transform_test_to_async(test_info) + + return async_tests + + +def fill_missing_tests_to_module( + module_tree: cst.Module, to_add_async_tests: Dict[str, MethodInfo] +) -> cst.Module: + """Fill missing async tests into the module.""" + transformer = FillMissingTestsTransformer(to_add_async_tests) + return module_tree.visit(transformer) diff --git a/scripts/scripts_utils/queue_ast.py b/scripts/scripts_utils/queue_ast.py new file mode 100644 index 0000000..db8dc88 --- /dev/null +++ b/scripts/scripts_utils/queue_ast.py @@ -0,0 +1,146 @@ +import libcst as cst +import re +import sys +from pathlib import Path +from typing import List, Set, Dict + + +sys.path.insert(0, str(Path(__name__).parent.parent.joinpath("scripts").resolve())) + +from scripts_utils.common_ast import MethodInfo # noqa: E402 + + +class AsyncFuncTransformer(cst.CSTTransformer): + to_replace_execute_func_attr: str = "_execute_operation" + target_execute_func_attr: str = "_execute_async_operation" + + def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + # Handle PGMQOperation.method calls + if isinstance(updated_node.func, cst.Attribute): + # Check if any argument is PGMQOperation.method + new_args = [] + for arg in updated_node.args: + if isinstance(arg.value, cst.Attribute) and isinstance( + arg.value.value, cst.Name + ): + if arg.value.value.value == "PGMQOperation": + # Add _async suffix to method name + new_attr = arg.value.with_changes( + attr=cst.Name(f"{arg.value.attr.value}_async") + ) + new_args.append(arg.with_changes(value=new_attr)) + continue + new_args.append(arg) + + # Replace `self._execute_operation` to `self._execute_async_operation` + if isinstance(updated_node.func.value, cst.Name): + if ( + updated_node.func.value.value == "self" + and updated_node.func.attr.value + == self.to_replace_execute_func_attr + ): + updated_node = updated_node.with_changes( + func=updated_node.func.with_changes( + attr=cst.Name(self.target_execute_func_attr) + ) + ) + + if new_args: + updated_node = updated_node.with_changes(args=new_args) + + return updated_node + + def leave_FunctionDef( + self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef + ) -> cst.FunctionDef: + # Transform function to async + new_node = updated_node.with_changes( + asynchronous=cst.Asynchronous(), + name=cst.Name(f"{updated_node.name.value}_async"), + ) + + # Transform docstring if exists + if updated_node.body.body and isinstance( + updated_node.body.body[0], cst.SimpleStatementLine + ): + first_stmt = updated_node.body.body[0] + if first_stmt.body and isinstance(first_stmt.body[0], cst.Expr): + expr = first_stmt.body[0] + if isinstance(expr.value, (cst.SimpleString, cst.ConcatenatedString)): + # Extract docstring value + if isinstance(expr.value, cst.SimpleString): + docstring = expr.value.value + else: + # For concatenated strings, we'll skip transformation for now + docstring = None + if docstring: + # Remove quotes to get actual string content + if docstring.startswith('"""') or docstring.startswith("'''"): + quote = docstring[:3] + content = docstring[3:-3] + elif docstring.startswith('"') or docstring.startswith("'"): + quote = docstring[0] + content = docstring[1:-1] + else: + content = docstring + quote = '"""' + + transformed_content = self.transform_docstring(content) + new_docstring = f"{quote}{transformed_content}{quote}" + + # Create new docstring node + new_expr = expr.with_changes( + value=cst.SimpleString(new_docstring) + ) + new_first_stmt = first_stmt.with_changes(body=[new_expr]) + + # Update body with new docstring + new_body = [new_first_stmt] + list(updated_node.body.body[1:]) + new_node = new_node.with_changes( + body=new_node.body.with_changes(body=new_body) + ) + + return new_node + + def leave_Return( + self, original_node: cst.Return, updated_node: cst.Return + ) -> cst.Return: + # Only wrap return value in await if it's a call expression + # (which is likely to be an operation that needs awaiting) + if updated_node.value and isinstance(updated_node.value, cst.Call): + return updated_node.with_changes( + value=cst.Await(expression=updated_node.value) + ) + return updated_node + + def transform_docstring(self, docstring: str) -> str: + """Transform docstring for async version.""" + # replace ` = pgmq_client.(` with ` = await pgmq_client._async(` + # replace `time.sleep` with `await asyncio.sleep` + modified = re.sub(r"(pgmq_client\.)(\w+)", r"await \1\2_async", docstring) + modified = re.sub(r"time\.sleep\(", r"await asyncio.sleep(", modified) + return modified + + +def transform_to_async( + transformer: AsyncFuncTransformer, method_info: MethodInfo +) -> MethodInfo: + orig_sync_func_node = method_info.node + # Deep copy is handled by libcst internally during transformation + async_node = orig_sync_func_node.visit(transformer) + + return MethodInfo(f"{method_info.base_name}_async", async_node) + + +def get_async_methods_to_add( + sync_methods: List[MethodInfo], missing_async: Set[str] +) -> Dict[str, MethodInfo]: + transformer = AsyncFuncTransformer() + async_methods: Dict[str, MethodInfo] = {} + for method_info in sync_methods: + if method_info.base_name in missing_async: + async_methods[method_info.base_name] = transform_to_async( + transformer, method_info + ) + + return async_methods diff --git a/scripts/scripts_utils/queue_test_ast.py b/scripts/scripts_utils/queue_test_ast.py new file mode 100644 index 0000000..494d479 --- /dev/null +++ b/scripts/scripts_utils/queue_test_ast.py @@ -0,0 +1,656 @@ +import libcst as cst +import re +import sys +from pathlib import Path +from typing import List, Set, Dict, Tuple + +sys.path.insert(0, str(Path(__file__).parent.parent.joinpath("scripts").resolve())) + + +class TestInfo: + """Information about a test function.""" + + def __init__(self, name: str, node: cst.FunctionDef): + self.name = name + self.node = node + self.is_test = name.startswith("test_") + self.is_async = name.endswith("_async") + self.base_name = name[:-6] if self.is_async else name + + +class ParseTestFunctionsVisitor(cst.CSTVisitor): + """CST visitor to parse test functions from test module""" + + def __init__(self): + self.tests: List[TestInfo] = [] + + def visit_FunctionDef(self, node: cst.FunctionDef): + func_name = node.name.value + if func_name.startswith("test_"): + self.tests.append(TestInfo(func_name, node)) + + +class AsyncTestTransformer(cst.CSTTransformer): + """Transform sync test to async test""" + + def leave_With(self, original_node: cst.With, updated_node: cst.With) -> cst.With: + """Transform 'with get_session_maker' to 'async with get_async_session_maker'""" + # Check if any with item uses get_session_maker or get_async_session_maker + # (get_async_session_maker might already be transformed by leave_Name) + new_items = [] + has_session_maker = False + + for item in updated_node.items: + if isinstance(item.item, cst.Call): + if isinstance(item.item.func, cst.Name): + func_name = item.item.func.value + if func_name in ("get_session_maker", "get_async_session_maker"): + has_session_maker = True + # Ensure it's get_async_session_maker + if func_name == "get_session_maker": + new_call = item.item.with_changes( + func=cst.Name("get_async_session_maker") + ) + new_items.append(item.with_changes(item=new_call)) + else: + new_items.append(item) + continue + new_items.append(item) + + # Only make it async if it uses session maker + if has_session_maker: + return updated_node.with_changes( + asynchronous=cst.Asynchronous(), items=new_items + ) + else: + # Keep as regular with statement for other cases (like pytest.raises) + return updated_node.with_changes(items=new_items) + + def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call: + """Transform method calls to async versions with await""" + if isinstance(updated_node.func, cst.Attribute): + # Check if calling method on pgmq object + if isinstance(updated_node.func.value, cst.Name): + var_name = updated_node.func.value.value + # Transform pgmq.method() to await pgmq.method_async() + if var_name == "pgmq": + method_name = updated_node.func.attr.value + # Add _async suffix to method name + new_attr = cst.Name(f"{method_name}_async") + new_func = updated_node.func.with_changes(attr=new_attr) + new_call = updated_node.with_changes(func=new_func) + # Wrap in await + return cst.Await(expression=new_call) + # Transform time.sleep() to await asyncio.sleep() + elif var_name == "time": + attr_name = updated_node.func.attr.value + if attr_name == "sleep": + # Change time.sleep to asyncio.sleep + new_func = updated_node.func.with_changes( + value=cst.Name("asyncio") + ) + new_call = updated_node.with_changes(func=new_func) + # Wrap in await + return cst.Await(expression=new_call) + + # Transform check_queue_exists to await check_queue_exists_async + if isinstance(updated_node.func, cst.Name): + if updated_node.func.value == "check_queue_exists": + new_call = updated_node.with_changes( + func=cst.Name("check_queue_exists_async") + ) + # Also need to update the db_session argument to async_db_session + if updated_node.args: + new_args = [] + for arg in updated_node.args: + if ( + isinstance(arg.value, cst.Name) + and arg.value.value == "db_session" + ): + new_args.append( + arg.with_changes(value=cst.Name("async_db_session")) + ) + else: + new_args.append(arg) + new_call = new_call.with_changes(args=new_args) + # Wrap in await + return cst.Await(expression=new_call) + + return updated_node + + def leave_Param( + self, original_node: cst.Param, updated_node: cst.Param + ) -> cst.Param: + """Transform function parameters to use async fixtures.""" + param_name = updated_node.name.value + + if param_name == "get_session_maker": + return updated_node.with_changes(name=cst.Name("get_async_session_maker")) + if param_name == "db_session": + return updated_node.with_changes(name=cst.Name("async_db_session")) + elif param_name == "pgmq_setup_teardown": + return updated_node.with_changes(name=cst.Name("async_pgmq_setup_teardown")) + elif param_name == "pgmq_partitioned_setup_teardown": + return updated_node.with_changes( + name=cst.Name("async_pgmq_partitioned_setup_teardown") + ) + + return updated_node + + def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.Name: + """Transform variable name references to use async fixture names.""" + name = updated_node.value + + # Transform fixture references in function body + if name == "pgmq_setup_teardown": + return updated_node.with_changes(value="async_pgmq_setup_teardown") + elif name == "pgmq_partitioned_setup_teardown": + return updated_node.with_changes( + value="async_pgmq_partitioned_setup_teardown" + ) + elif name == "get_session_maker": + return updated_node.with_changes(value="get_async_session_maker") + elif name == "db_session": + return updated_node.with_changes(value="async_db_session") + + return updated_node + + def leave_FunctionDef( + self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef + ) -> cst.FunctionDef: + """Transform function to async and update name""" + # Transform function to async + new_node = updated_node.with_changes( + asynchronous=cst.Asynchronous(), + name=cst.Name(f"{updated_node.name.value}_async"), + ) + + # Transform docstring if exists + if updated_node.body.body and isinstance( + updated_node.body.body[0], cst.SimpleStatementLine + ): + first_stmt = updated_node.body.body[0] + if first_stmt.body and isinstance(first_stmt.body[0], cst.Expr): + expr = first_stmt.body[0] + if isinstance(expr.value, (cst.SimpleString, cst.ConcatenatedString)): + # Extract docstring value + if isinstance(expr.value, cst.SimpleString): + docstring = expr.value.value + else: + # For concatenated strings, skip transformation + docstring = None + + if docstring: + # Remove quotes to get actual string content + if docstring.startswith('"""') or docstring.startswith("'''"): + quote = docstring[:3] + content = docstring[3:-3] + elif docstring.startswith('"') or docstring.startswith("'"): + quote = docstring[0] + content = docstring[1:-1] + else: + content = docstring + quote = '"""' + + transformed_content = self.transform_docstring(content) + new_docstring = f"{quote}{transformed_content}{quote}" + + # Create new docstring node + new_expr = expr.with_changes( + value=cst.SimpleString(new_docstring) + ) + new_first_stmt = first_stmt.with_changes(body=[new_expr]) + + # Update body with new docstring + new_body = [new_first_stmt] + list(updated_node.body.body[1:]) + new_node = new_node.with_changes( + body=new_node.body.with_changes(body=new_body) + ) + + return new_node + + def transform_docstring(self, docstring: str) -> str: + """Transform docstring for async test version.""" + # Replace pgmq.method( with await pgmq.method_async( + modified = re.sub(r"(pgmq\.)(\w+)\(", r"await \1\2_async(", docstring) + # Replace time.sleep with await asyncio.sleep + modified = re.sub(r"time\.sleep\(", r"await asyncio.sleep(", modified) + return modified + + +class FillMissingTestsToModule(cst.CSTTransformer): + """CST Transformer to fill missing async tests to test module""" + + def __init__(self, to_add_async_tests: Dict[str, Tuple[TestInfo, TestInfo]]): + self.to_add_async_tests = to_add_async_tests + self.body_statements = [] + self.has_asyncio_import = False + self.has_check_queue_exists_async_import = False + + def visit_Module(self, node: cst.Module) -> bool: + """Collect all statements and check for asyncio import""" + self.body_statements = list(node.body) + # Check if asyncio is already imported + for stmt in node.body: + if isinstance(stmt, cst.SimpleStatementLine): + for item in stmt.body: + if isinstance(item, cst.Import): + for name in item.names: + if isinstance(name, cst.ImportAlias): + if ( + isinstance(name.name, cst.Name) + and name.name.value == "asyncio" + ): + self.has_asyncio_import = True + break + # Check for check_queue_exists_async import + if isinstance(item, cst.ImportFrom): + if ( + isinstance(item.module, cst.Attribute) + and isinstance(item.module.value, cst.Name) + and item.module.value.value == "tests" + and isinstance(item.module.attr, cst.Name) + and item.module.attr.value == "_utils" + ): + if isinstance(item.names, cst.ImportStar): + self.has_check_queue_exists_async_import = True + else: + for name in item.names: + if isinstance(name, cst.ImportAlias): + if ( + isinstance(name.name, cst.Name) + and name.name.value + == "check_queue_exists_async" + ): + self.has_check_queue_exists_async_import = ( + True + ) + break + return True + + def leave_Module( + self, original_node: cst.Module, updated_node: cst.Module + ) -> cst.Module: + """Add async tests after their sync counterparts and add asyncio import if needed""" + new_body = [] + inserted_asyncio = False + updated_check_queue_exists_import = False + updated_fixture_imports = False + updated_use_fixtures = False + seen_imports_from_utils = False + + for stmt in updated_node.body: + # This section is removed - we'll add async tests after sync tests in the main loop below + + # Update fixture imports from tests.fixture_deps + if not updated_fixture_imports and isinstance( + stmt, cst.SimpleStatementLine + ): + for item in stmt.body: + if isinstance(item, cst.ImportFrom): + if ( + isinstance(item.module, cst.Attribute) + and isinstance(item.module.value, cst.Name) + and item.module.value.value == "tests" + and isinstance(item.module.attr, cst.Name) + and item.module.attr.value == "fixture_deps" + ): + # Add async fixture imports if needed + if not isinstance(item.names, cst.ImportStar): + has_pgmq_setup_teardown = False + has_async_pgmq_setup_teardown = False + has_pgmq_partitioned = False + has_async_pgmq_partitioned = False + + for name in item.names: + if isinstance(name, cst.ImportAlias) and isinstance( + name.name, cst.Name + ): + if name.name.value == "pgmq_setup_teardown": + has_pgmq_setup_teardown = True + elif ( + name.name.value + == "async_pgmq_setup_teardown" + ): + has_async_pgmq_setup_teardown = True + elif ( + name.name.value + == "pgmq_partitioned_setup_teardown" + ): + has_pgmq_partitioned = True + elif ( + name.name.value + == "async_pgmq_partitioned_setup_teardown" + ): + has_async_pgmq_partitioned = True + + # Add async versions if sync versions exist but async don't + new_names = list(item.names) + if ( + has_pgmq_setup_teardown + and not has_async_pgmq_setup_teardown + ): + new_names.append( + cst.ImportAlias( + name=cst.Name("async_pgmq_setup_teardown") + ) + ) + if ( + has_pgmq_partitioned + and not has_async_pgmq_partitioned + ): + new_names.append( + cst.ImportAlias( + name=cst.Name( + "async_pgmq_partitioned_setup_teardown" + ) + ) + ) + + if len(new_names) > len(item.names): + new_import = item.with_changes(names=new_names) + new_stmt = stmt.with_changes(body=[new_import]) + new_body.append(new_stmt) + updated_fixture_imports = True + continue + + # Update check_queue_exists import to include check_queue_exists_async + if isinstance(stmt, cst.SimpleStatementLine): + for item in stmt.body: + if isinstance(item, cst.ImportFrom): + if ( + isinstance(item.module, cst.Attribute) + and isinstance(item.module.value, cst.Name) + and item.module.value.value == "tests" + and isinstance(item.module.attr, cst.Name) + and item.module.attr.value == "_utils" + ): + # Skip duplicate imports from tests._utils + if seen_imports_from_utils: + continue + + # Check if check_queue_exists is imported + if not isinstance(item.names, cst.ImportStar): + has_check_queue_exists = False + has_check_queue_exists_async = False + for name in item.names: + if isinstance(name, cst.ImportAlias): + if isinstance(name.name, cst.Name): + if name.name.value == "check_queue_exists": + has_check_queue_exists = True + elif ( + name.name.value + == "check_queue_exists_async" + ): + has_check_queue_exists_async = True + + # If check_queue_exists is imported but not check_queue_exists_async, add it + if not updated_check_queue_exists_import: + if ( + has_check_queue_exists + and not has_check_queue_exists_async + ): + new_names = list(item.names) + new_names.append( + cst.ImportAlias( + name=cst.Name( + "check_queue_exists_async" + ) + ) + ) + new_import = item.with_changes(names=new_names) + new_stmt = stmt.with_changes(body=[new_import]) + new_body.append(new_stmt) + updated_check_queue_exists_import = True + seen_imports_from_utils = True + continue + else: + # Import already has both or is fine as-is + new_body.append(stmt) + seen_imports_from_utils = True + continue + + # Insert asyncio import after other imports if needed + if not inserted_asyncio and not self.has_asyncio_import: + if isinstance(stmt, cst.SimpleStatementLine): + # Check if this is the last import statement + is_import = any( + isinstance(item, (cst.Import, cst.ImportFrom)) + for item in stmt.body + ) + if is_import: + # Look ahead to see if next statement is not an import + current_idx = list(updated_node.body).index(stmt) + if current_idx + 1 < len(updated_node.body): + next_stmt = list(updated_node.body)[current_idx + 1] + if isinstance(next_stmt, cst.SimpleStatementLine): + next_is_import = any( + isinstance(item, (cst.Import, cst.ImportFrom)) + for item in next_stmt.body + ) + if not next_is_import: + # This is the last import, add asyncio after it + new_body.append(stmt) + new_body.append( + cst.SimpleStatementLine( + body=[ + cst.Import( + names=[ + cst.ImportAlias( + name=cst.Name("asyncio") + ) + ] + ) + ] + ) + ) + inserted_asyncio = True + continue + else: + # This is the last statement, add asyncio after it + new_body.append(stmt) + new_body.append( + cst.SimpleStatementLine( + body=[ + cst.Import( + names=[ + cst.ImportAlias( + name=cst.Name("asyncio") + ) + ] + ) + ] + ) + ) + inserted_asyncio = True + continue + + # Update use_fixtures list to include async versions + if not updated_use_fixtures and isinstance(stmt, cst.SimpleStatementLine): + for item in stmt.body: + if isinstance(item, cst.Assign): + for target in item.targets: + if ( + isinstance(target.target, cst.Name) + and target.target.value == "use_fixtures" + ): + # Check if value is a list + if isinstance(item.value, cst.List): + has_pgmq_setup_teardown = False + has_async_pgmq_setup_teardown = False + has_pgmq_partitioned = False + has_async_pgmq_partitioned = False + + for elem in item.value.elements: + if isinstance(elem.value, cst.Name): + if ( + elem.value.value + == "pgmq_setup_teardown" + ): + has_pgmq_setup_teardown = True + elif ( + elem.value.value + == "async_pgmq_setup_teardown" + ): + has_async_pgmq_setup_teardown = True + elif ( + elem.value.value + == "pgmq_partitioned_setup_teardown" + ): + has_pgmq_partitioned = True + elif ( + elem.value.value + == "async_pgmq_partitioned_setup_teardown" + ): + has_async_pgmq_partitioned = True + + # Add async versions to the list + new_elements = list(item.value.elements) + if ( + has_pgmq_setup_teardown + and not has_async_pgmq_setup_teardown + ): + new_elements.append( + cst.Element( + value=cst.Name( + "async_pgmq_setup_teardown" + ) + ) + ) + if ( + has_pgmq_partitioned + and not has_async_pgmq_partitioned + ): + new_elements.append( + cst.Element( + value=cst.Name( + "async_pgmq_partitioned_setup_teardown" + ) + ) + ) + + if len(new_elements) > len(item.value.elements): + new_list = item.value.with_changes( + elements=new_elements + ) + new_assign = item.with_changes(value=new_list) + new_stmt = stmt.with_changes(body=[new_assign]) + new_body.append(new_stmt) + updated_use_fixtures = True + continue + + new_body.append(stmt) + # If this is a sync test function, check if we need to add async versions after it + if isinstance(stmt, cst.FunctionDef): + func_name = stmt.name.value + if func_name in self.to_add_async_tests: + ( + async_test_with_decorator, + async_test_without_decorator, + ) = self.to_add_async_tests[func_name] + + # Add decorator for first version + decorator = cst.Decorator( + decorator=cst.Attribute( + value=cst.Attribute( + value=cst.Name("pytest"), attr=cst.Name("mark") + ), + attr=cst.Name("asyncio"), + ) + ) + + # First async test WITH decorator + async_test_node = async_test_with_decorator.node + if async_test_node.decorators: + decorated_async = async_test_node.with_changes( + decorators=[decorator] + list(async_test_node.decorators) + ) + else: + decorated_async = async_test_node.with_changes( + decorators=[decorator] + ) + + # Add blank lines and first async test (with decorator) + new_body.append(cst.EmptyLine(whitespace=cst.SimpleWhitespace(""))) + new_body.append(cst.EmptyLine(whitespace=cst.SimpleWhitespace(""))) + new_body.append(decorated_async) + + return updated_node.with_changes(body=new_body) + + +def parse_test_functions_from_module( + module_tree: cst.Module, +) -> Tuple[List[TestInfo], Set[str]]: + """ + Parse test functions from test module CST Tree + + Args: + module_tree: cst.Module + + Returns: + Tuple of all_tests, missing_async_set + """ + + analyzer = ParseTestFunctionsVisitor() + module_tree.visit(analyzer) + + # Categorize test functions + async_tests_set = set() + missing_async_set = set() + + for test_info in analyzer.tests: + if test_info.is_async: + async_tests_set.add(test_info.base_name) + + # Find missing async tests + for test_info in analyzer.tests: + if not test_info.is_async and test_info.base_name not in async_tests_set: + missing_async_set.add(test_info.base_name) + + return analyzer.tests, missing_async_set + + +def transform_to_async_test( + transformer: AsyncTestTransformer, test_info: TestInfo +) -> TestInfo: + """Transform a sync test to async test. + + Args: + transformer: AsyncTestTransformer instance for CST transformations + test_info: TestInfo object containing sync test metadata + + Returns: + TestInfo object with transformed async test function + """ + orig_sync_func_node = test_info.node + async_node = orig_sync_func_node.visit(transformer) + + return TestInfo(f"{test_info.base_name}_async", async_node) + + +def get_async_tests_to_add( + sync_tests: List[TestInfo], missing_async: Set[str] +) -> Dict[str, Tuple[TestInfo, TestInfo]]: + """Generate async test functions for missing tests. + + Returns a dict mapping sync test name to a tuple of (async_test_with_decorator, async_test_without_decorator) + """ + transformer = AsyncTestTransformer() + async_tests: Dict[str, Tuple[TestInfo, TestInfo]] = {} + + for test_info in sync_tests: + if test_info.name in missing_async and not test_info.is_async: + # Generate the async test + async_test = transform_to_async_test(transformer, test_info) + # Store both versions (we'll add decorators later in the transformer) + async_tests[test_info.name] = (async_test, async_test) + + return async_tests + + +def fill_missing_tests_to_module( + module_tree: cst.Module, to_add_async_tests: Dict[str, Tuple[TestInfo, TestInfo]] +) -> cst.Module: + """Fill missing async tests to module""" + transformer = FillMissingTestsToModule(to_add_async_tests=to_add_async_tests) + return module_tree.visit(transformer) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_utils.py b/tests/_utils.py index 237b489..f125552 100644 --- a/tests/_utils.py +++ b/tests/_utils.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING + from sqlalchemy import text -from sqlalchemy.orm import Session + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + from sqlalchemy.ext.asyncio import AsyncSession -def check_queue_exists(db_session: Session, queue_name: str) -> bool: +def check_queue_exists(db_session: "Session", queue_name: str) -> bool: row = db_session.execute( text( "SELECT queue_name FROM pgmq.list_queues() WHERE queue_name = :queue_name ;" @@ -10,3 +15,17 @@ def check_queue_exists(db_session: Session, queue_name: str) -> bool: {"queue_name": queue_name}, ).first() return row is not None and row[0] == queue_name + + +async def check_queue_exists_async( + async_db_session: "AsyncSession", queue_name: str +) -> bool: + row = ( + await async_db_session.execute( + text( + "SELECT queue_name FROM pgmq.list_queues() WHERE queue_name = :queue_name ;" + ), + {"queue_name": queue_name}, + ) + ).first() + return row is not None and row[0] == queue_name diff --git a/tests/conftest.py b/tests/conftest.py index 9bf6393..8d00b5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,8 @@ def pytest_generate_tests(metafunc: pytest.Metafunc): "pgmq_by_async_dsn", "pgmq_by_async_engine", "pgmq_by_async_session_maker", + "pgmq_by_async_dsn_and_async_engine", + "pgmq_by_async_dsn_and_async_session_maker", ] # Determine which fixtures to use @@ -207,6 +209,12 @@ def pgmq_by_dsn_and_engine(get_dsn, get_engine): return pgmq +@pytest.fixture(scope="function") +def pgmq_by_async_dsn_and_async_engine(get_async_dsn, get_async_engine): + pgmq = PGMQueue(dsn=get_async_dsn, engine=get_async_engine) + return pgmq + + @pytest.fixture(scope="function") def pgmq_by_dsn_and_session_maker(get_dsn, get_session_maker): pgmq = PGMQueue(dsn=get_dsn, session_maker=get_session_maker) @@ -214,5 +222,18 @@ def pgmq_by_dsn_and_session_maker(get_dsn, get_session_maker): @pytest.fixture(scope="function") -def db_session(get_session_maker) -> Session: - return get_session_maker() +def pgmq_by_async_dsn_and_async_session_maker(get_async_dsn, get_async_session_maker): + pgmq = PGMQueue(dsn=get_async_dsn, session_maker=get_async_session_maker) + return pgmq + + +@pytest.fixture(scope="function") +def db_session(get_session_maker) -> "Session": + with get_session_maker() as session: + yield session + + +@pytest.fixture(scope="function") +async def async_db_session(get_async_session_maker) -> "AsyncSession": + async with get_async_session_maker() as session: + yield session diff --git a/tests/fixture_deps.py b/tests/fixture_deps.py index d691f8c..713d4f2 100644 --- a/tests/fixture_deps.py +++ b/tests/fixture_deps.py @@ -1,14 +1,14 @@ import uuid -from typing import Tuple +from typing import Tuple, Generator from inspect import iscoroutinefunction import pytest from pgmq_sqlalchemy import PGMQueue -from tests.constant import ASYNC_DRIVERS -from tests._utils import check_queue_exists +from tests.constant import SYNC_DRIVERS, ASYNC_DRIVERS +from tests._utils import check_queue_exists, check_queue_exists_async -PGMQ_WITH_QUEUE = Tuple[PGMQueue, str] +PGMQ_WITH_QUEUE = Generator[Tuple[PGMQueue, str], None, None] @pytest.fixture(scope="function") @@ -33,6 +33,10 @@ def test_something(pgmq_all_variants): pytest.skip( reason=f"Skip sync test: {request.function.__name__}, as driver: {driver_from_cli} is async" ) + if driver_from_cli and (driver_from_cli in SYNC_DRIVERS and is_async_test): + pytest.skip( + reason=f"Skip async test: {request.function.__name__}, as driver: {driver_from_cli} is sync" + ) return request.getfixturevalue(request.param) @@ -64,6 +68,36 @@ def test_something(pgmq_setup_teardown): assert check_queue_exists(db_session, queue_name) is False +@pytest.fixture(scope="function") +async def async_pgmq_setup_teardown( + pgmq_all_variants: PGMQueue, async_db_session +) -> PGMQ_WITH_QUEUE: + """ + Fixture that provides a PGMQueue instance with a unique temporary queue with setup and teardown. + + Args: + pgmq_all_variants (PGMQueue): The PGMQueue instance (parametrized across all variants). + async_db_session (sqlalchemy.ext.asyncio.AsyncSession): The SQLAlchemy session object. + + Yields: + tuple[PGMQueue,str]: A tuple containing the PGMQueue instance and the name of the temporary queue. + + Usage: + def test_something(pgmq_setup_teardown): + pgmq, queue_name = pgmq_setup_teardown + # test code here + + """ + pgmq = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + assert await check_queue_exists_async(async_db_session, queue_name) is False + await pgmq.create_queue_async(queue_name) + assert await check_queue_exists_async(async_db_session, queue_name) is True + yield pgmq, queue_name + await pgmq.drop_queue_async(queue_name) + assert await check_queue_exists_async(async_db_session, queue_name) is False + + @pytest.fixture(scope="function") def pgmq_partitioned_setup_teardown( pgmq_all_variants: PGMQueue, db_session @@ -92,3 +126,33 @@ def test_something(pgmq_partitioned_setup_teardown): yield pgmq, queue_name pgmq.drop_queue(queue_name, partitioned=True) assert check_queue_exists(db_session, queue_name) is False + + +@pytest.fixture(scope="function") +async def async_pgmq_partitioned_setup_teardown( + pgmq_all_variants: PGMQueue, async_db_session +) -> PGMQ_WITH_QUEUE: + """ + Fixture that provides a PGMQueue instance with a unique temporary partitioned queue with setup and teardown. + + Args: + pgmq_all_variants (PGMQueue): The PGMQueue instance (parametrized across all variants). + async_db_session (sqlalchemy.ext.asyncio.AsyncSession): The SQLAlchemy session object. + + Yields: + tuple[PGMQueue,str]: A tuple containing the PGMQueue instance and the name of the temporary queue. + + Usage: + def test_something(pgmq_partitioned_setup_teardown): + pgmq, queue_name = pgmq_partitioned_setup_teardown + # test code here + + """ + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + assert await check_queue_exists_async(async_db_session, queue_name) is False + await pgmq.create_partitioned_queue_async(queue_name) + assert await check_queue_exists_async(async_db_session, queue_name) is True + yield pgmq, queue_name + await pgmq.drop_queue_async(queue_name, partitioned=True) + assert await check_queue_exists_async(async_db_session, queue_name) is False diff --git a/tests/test_operation.py b/tests/test_operation.py index ce5bd62..b57e339 100644 --- a/tests/test_operation.py +++ b/tests/test_operation.py @@ -3,6 +3,7 @@ This test suite tests the PGMQOperation class methods directly, which are transaction-friendly static methods that accept sessions. """ + import time import uuid @@ -61,6 +62,25 @@ def test_create_unlogged_queue_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_create_unlogged_queue_async(get_async_session_maker, db_session): + """Test creating an unlogged queue using PGMQOperation asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=True, session=session, commit=True + ) + + assert check_queue_exists(db_session, queue_name) is True + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=False, session=session, commit=True + ) + + def test_validate_queue_name_sync(get_session_maker): """Test queue name validation.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -70,12 +90,32 @@ def test_validate_queue_name_sync(get_session_maker): PGMQOperation.validate_queue_name(queue_name, session=session, commit=True) # Should raise for name that's too long (either ProgrammingError or InternalError depending on driver) - with pytest.raises((ProgrammingError, InternalError)) as e: + with pytest.raises((ProgrammingError, InternalError, Exception)) as e: PGMQOperation.validate_queue_name("a" * 49, session=session, commit=True) error_msg = str(e.value.orig) if hasattr(e.value, "orig") else str(e.value) assert "queue name is too long" in error_msg +@pytest.mark.asyncio +async def test_validate_queue_name_async(get_async_session_maker): + """Test queue name validation asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + async with get_async_session_maker() as session: + # Should not raise for valid name + await PGMQOperation.validate_queue_name_async( + queue_name, session=session, commit=True + ) + + # Should raise for name that's too long (either ProgrammingError or InternalError depending on driver) + with pytest.raises((ProgrammingError, InternalError, Exception)) as e: + await PGMQOperation.validate_queue_name_async( + "a" * 49, session=session, commit=True + ) + error_msg = str(e.value.orig) if hasattr(e.value, "orig") else str(e.value) + assert "queue name is too long" in error_msg + + def test_list_queues_sync(get_session_maker, db_session): """Test listing queues.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -99,6 +139,30 @@ def test_list_queues_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_list_queues_async(get_async_session_maker, db_session): + """Test listing queues asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create a queue + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=False, session=session, commit=True + ) + + # List queues + async with get_async_session_maker() as session: + queues = await PGMQOperation.list_queues_async(session=session, commit=True) + + assert queue_name in queues + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=False, session=session, commit=True + ) + + def test_send_and_read_sync(get_session_maker, db_session): """Test sending and reading messages.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -166,6 +230,41 @@ def test_send_batch_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_send_batch_async(get_async_session_maker, db_session): + """Test sending a batch of messages asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + messages = [{"key": f"value{i}"} for i in range(5)] + + # Create queue + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=False, session=session, commit=True + ) + + # Send batch + async with get_async_session_maker() as session: + msg_ids = await PGMQOperation.send_batch_async( + queue_name, messages, delay=0, session=session, commit=True + ) + + assert len(msg_ids) == 5 + + # Read batch + async with get_async_session_maker() as session: + msgs = await PGMQOperation.read_batch_async( + queue_name, vt=30, batch_size=5, session=session, commit=True + ) + + assert len(msgs) == 5 + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=False, session=session, commit=True + ) + + def test_pop_sync(get_session_maker, db_session): """Test popping a message from the queue.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -199,6 +298,40 @@ def test_pop_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_pop_async(get_async_session_maker, db_session): + """Test popping a message from the queue asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create queue and send message + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=False, session=session, commit=True + ) + msg_id = await PGMQOperation.send_async( + queue_name, MSG, delay=0, session=session, commit=True + ) + + # Pop message + async with get_async_session_maker() as session: + msg = await PGMQOperation.pop_async(queue_name, session=session, commit=True) + + assert msg is not None + assert msg.msg_id == msg_id + + # Verify queue is empty + async with get_async_session_maker() as session: + msg2 = await PGMQOperation.pop_async(queue_name, session=session, commit=True) + + assert msg2 is None + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=False, session=session, commit=True + ) + + def test_delete_sync(get_session_maker, db_session): """Test deleting a message.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -225,6 +358,35 @@ def test_delete_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_delete_async(get_async_session_maker, db_session): + """Test deleting a message asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create queue and send message + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=False, session=session, commit=True + ) + msg_id = await PGMQOperation.send_async( + queue_name, MSG, delay=0, session=session, commit=True + ) + + # Delete message + async with get_async_session_maker() as session: + deleted = await PGMQOperation.delete_async( + queue_name, msg_id, session=session, commit=True + ) + + assert deleted is True + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=False, session=session, commit=True + ) + + def test_set_vt_sync(get_session_maker, db_session): """Test setting visibility timeout.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -255,6 +417,37 @@ def test_set_vt_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_set_vt_async(get_async_session_maker, db_session): + """Test setting visibility timeout asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create queue and send message + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=False, session=session, commit=True + ) + msg_id = await PGMQOperation.send_async( + queue_name, MSG, delay=0, session=session, commit=True + ) + # Read message to set initial vt + await PGMQOperation.read_async(queue_name, vt=5, session=session, commit=True) + + # Set new vt + async with get_async_session_maker() as session: + msg = await PGMQOperation.set_vt_async( + queue_name, msg_id, vt=60, session=session, commit=True + ) + + assert msg is not None + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=False, session=session, commit=True + ) + + def test_archive_sync(get_session_maker, db_session): """Test archiving a message.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -283,6 +476,35 @@ def test_archive_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_archive_async(get_async_session_maker, db_session): + """Test archiving a message asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create queue and send message + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=False, session=session, commit=True + ) + msg_id = await PGMQOperation.send_async( + queue_name, MSG, delay=0, session=session, commit=True + ) + + # Archive message + async with get_async_session_maker() as session: + archived = await PGMQOperation.archive_async( + queue_name, msg_id, session=session, commit=True + ) + + assert archived is True + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=False, session=session, commit=True + ) + + def test_metrics_sync(get_session_maker, db_session): """Test getting queue metrics.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -358,6 +580,43 @@ def test_metrics_all_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_metrics_all_async(get_async_session_maker, db_session): + """Test getting metrics for all queues asynchronously.""" + queue_name1 = f"test_queue_{uuid.uuid4().hex}" + queue_name2 = f"test_queue_{uuid.uuid4().hex}" + + # Create two queues + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name1, unlogged=False, session=session, commit=True + ) + await PGMQOperation.create_queue_async( + queue_name2, unlogged=False, session=session, commit=True + ) + + # Get metrics for all queues + async with get_async_session_maker() as session: + all_metrics = await PGMQOperation.metrics_all_async( + session=session, commit=True + ) + + assert all_metrics is not None + assert len(all_metrics) >= 2 + queue_names = [m.queue_name for m in all_metrics] + assert queue_name1 in queue_names + assert queue_name2 in queue_names + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name1, partitioned=False, session=session, commit=True + ) + await PGMQOperation.drop_queue_async( + queue_name2, partitioned=False, session=session, commit=True + ) + + def test_transaction_rollback_sync(get_session_maker, db_session): """Test that operations can be rolled back when commit=False.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -373,6 +632,22 @@ def test_transaction_rollback_sync(get_session_maker, db_session): assert check_queue_exists(db_session, queue_name) is False +@pytest.mark.asyncio +async def test_transaction_rollback_async(get_async_session_maker, db_session): + """Test that operations can be rolled back when commit=False asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create queue with commit=False, then rollback + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=False, session=session, commit=False + ) + await session.rollback() + + # Queue should not exist + assert check_queue_exists(db_session, queue_name) is False + + def test_transaction_commit_sync(get_session_maker, db_session): """Test that operations are committed when commit=True.""" queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -393,6 +668,27 @@ def test_transaction_commit_sync(get_session_maker, db_session): ) +@pytest.mark.asyncio +async def test_transaction_commit_async(get_async_session_maker, db_session): + """Test that operations are committed when commit=True asynchronously.""" + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create queue with commit=True + async with get_async_session_maker() as session: + await PGMQOperation.create_queue_async( + queue_name, unlogged=False, session=session, commit=True + ) + + # Queue should exist + assert check_queue_exists(db_session, queue_name) is True + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=False, session=session, commit=True + ) + + # Async tests @@ -779,6 +1075,51 @@ def test_create_time_based_partitioned_queue_sync(get_session_maker, db_session) ) +@pytest.mark.asyncio +async def test_create_time_based_partitioned_queue_async( + get_async_session_maker, db_session +): + """Test creating a time-based partitioned queue asynchronously.""" + queue_name = f"time_{uuid.uuid4().hex[:20]}" + + # First ensure pg_partman extension is available + try: + async with get_async_session_maker() as session: + await PGMQOperation.check_pg_partman_ext_async(session=session, commit=True) + except Exception as e: + pytest.skip(f"pg_partman extension not available: {e}") + + # Create partitioned queue with time-based partitioning + async with get_async_session_maker() as session: + await PGMQOperation.create_partitioned_queue_async( + queue_name, + partition_interval="1 day", + retention_interval="7 days", + session=session, + commit=True, + ) + + assert check_queue_exists(db_session, queue_name) is True + + # Test sending and reading from time-based partitioned queue + async with get_async_session_maker() as session: + msg_id = await PGMQOperation.send_async( + queue_name, MSG, delay=0, session=session, commit=True + ) + msg = await PGMQOperation.read_async( + queue_name, vt=30, session=session, commit=True + ) + + assert msg is not None + assert msg.msg_id == msg_id + + # Clean up + async with get_async_session_maker() as session: + await PGMQOperation.drop_queue_async( + queue_name, partitioned=True, session=session, commit=True + ) + + # Async tests for newly added coverage diff --git a/tests/test_queue.py b/tests/test_queue.py index e4956f7..e51151f 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -11,10 +11,22 @@ pgmq_setup_teardown, pgmq_partitioned_setup_teardown, pgmq_all_variants, + async_pgmq_setup_teardown, + async_pgmq_partitioned_setup_teardown, ) -from tests._utils import check_queue_exists +from tests._utils import check_queue_exists, check_queue_exists_async + from tests.constant import MSG, LOCK_FILE_NAME +import asyncio + +use_fixtures = [ + pgmq_setup_teardown, + pgmq_partitioned_setup_teardown, + pgmq_all_variants, + async_pgmq_setup_teardown, + async_pgmq_partitioned_setup_teardown, +] use_fixtures = [pgmq_setup_teardown, pgmq_partitioned_setup_teardown, pgmq_all_variants] @@ -26,6 +38,14 @@ def test_create_queue(pgmq_all_variants, db_session): assert check_queue_exists(db_session, queue_name) is True +@pytest.mark.asyncio +async def test_create_queue_async(pgmq_all_variants, async_db_session): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + await pgmq.create_queue_async(queue_name) + assert await check_queue_exists_async(async_db_session, queue_name) is True + + def test_create_partitioned_queue(pgmq_all_variants, db_session): pgmq: PGMQueue = pgmq_all_variants queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -33,6 +53,14 @@ def test_create_partitioned_queue(pgmq_all_variants, db_session): assert check_queue_exists(db_session, queue_name) is True +@pytest.mark.asyncio +async def test_create_partitioned_queue_async(pgmq_all_variants, async_db_session): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + await pgmq.create_partitioned_queue_async(queue_name) + assert await check_queue_exists_async(async_db_session, queue_name) is True + + def test_create_same_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE, db_session): pgmq, queue_name = pgmq_setup_teardown pgmq.create_queue(queue_name) @@ -43,6 +71,19 @@ def test_create_same_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE, db_session): assert check_queue_exists(db_session, queue_name) is True +@pytest.mark.asyncio +async def test_create_same_queue_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, async_db_session +): + pgmq, queue_name = async_pgmq_setup_teardown + await pgmq.create_queue_async(queue_name) + assert await check_queue_exists_async(async_db_session, queue_name) is True + await pgmq.create_queue_async(queue_name) + # `create_queue` with the same queue name should not raise an exception + # and the queue should still exist + assert await check_queue_exists_async(async_db_session, queue_name) is True + + def test_validate_queue_name(pgmq_all_variants): pgmq: PGMQueue = pgmq_all_variants queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -54,11 +95,29 @@ def test_validate_queue_name(pgmq_all_variants): assert "queue name is too long, maximum length is 48 characters" in error_msg +@pytest.mark.asyncio +async def test_validate_queue_name_async(pgmq_all_variants): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + await pgmq.validate_queue_name_async(queue_name) + # `queue_name` should be a less than 48 characters + with pytest.raises(Exception) as e: + await pgmq.validate_queue_name_async("a" * 49) + error_msg: str = str(e.value.orig) + assert "queue name is too long, maximum length is 48 characters" in error_msg + + def test_drop_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): _ = pgmq_setup_teardown pass +@pytest.mark.asyncio +async def test_drop_queue_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + _ = async_pgmq_setup_teardown + pass + + def test_drop_non_exist_queue(pgmq_all_variants, db_session): pgmq: PGMQueue = pgmq_all_variants queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -67,11 +126,28 @@ def test_drop_non_exist_queue(pgmq_all_variants, db_session): pgmq.drop_queue(queue_name) +@pytest.mark.asyncio +async def test_drop_non_exist_queue_async(pgmq_all_variants, async_db_session): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + assert await check_queue_exists_async(async_db_session, queue_name) is False + with pytest.raises(ProgrammingError): + await pgmq.drop_queue_async(queue_name) + + def test_drop_partitioned_queue(pgmq_partitioned_setup_teardown: PGMQ_WITH_QUEUE): _ = pgmq_partitioned_setup_teardown pass +@pytest.mark.asyncio +async def test_drop_partitioned_queue_async( + async_pgmq_partitioned_setup_teardown: PGMQ_WITH_QUEUE, +): + _ = async_pgmq_partitioned_setup_teardown + pass + + def test_drop_non_exist_partitioned_queue(pgmq_all_variants, db_session): pgmq: PGMQueue = pgmq_all_variants queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -80,18 +156,45 @@ def test_drop_non_exist_partitioned_queue(pgmq_all_variants, db_session): pgmq.drop_queue(queue_name, partitioned=True) +@pytest.mark.asyncio +async def test_drop_non_exist_partitioned_queue_async( + pgmq_all_variants, async_db_session +): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + assert await check_queue_exists_async(async_db_session, queue_name) is False + with pytest.raises(ProgrammingError): + await pgmq.drop_queue_async(queue_name, partitioned=True) + + def test_list_queues(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown queues = pgmq.list_queues() assert queue_name in queues +@pytest.mark.asyncio +async def test_list_queues_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + queues = await pgmq.list_queues_async() + assert queue_name in queues + + def test_list_partitioned_queues(pgmq_partitioned_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_partitioned_setup_teardown queues = pgmq.list_queues() assert queue_name in queues +@pytest.mark.asyncio +async def test_list_partitioned_queues_async( + async_pgmq_partitioned_setup_teardown: PGMQ_WITH_QUEUE, +): + pgmq, queue_name = async_pgmq_partitioned_setup_teardown + queues = await pgmq.list_queues_async() + assert queue_name in queues + + def test_send_and_read_msg(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -101,6 +204,16 @@ def test_send_and_read_msg(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert msg_read.msg_id == msg_id +@pytest.mark.asyncio +async def test_send_and_read_msg_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_id: int = await pgmq.send_async(queue_name, msg) + msg_read = await pgmq.read_async(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + def test_send_and_read_msg_with_delay(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -116,6 +229,24 @@ def test_send_and_read_msg_with_delay(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert msg_read.msg_id == msg_id +@pytest.mark.asyncio +async def test_send_and_read_msg_with_delay_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_id: int = await pgmq.send_async(queue_name, msg, delay=2) + msg_read = await pgmq.read_async(queue_name) + assert msg_read is None + await asyncio.sleep(1) + msg_read = await pgmq.read_async(queue_name) + assert msg_read is None + await asyncio.sleep(1.1) + msg_read = await pgmq.read_async(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + def test_send_and_read_msg_with_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -132,6 +263,25 @@ def test_send_and_read_msg_with_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert msg_read.msg_id == msg_id +@pytest.mark.asyncio +async def test_send_and_read_msg_with_vt_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_id: int = await pgmq.send_async(queue_name, msg) + msg_read = await pgmq.read_async(queue_name, vt=2) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + await asyncio.sleep(1.5) + msg_read = await pgmq.read_async(queue_name) + assert msg_read is None + await asyncio.sleep(0.6) + msg_read = await pgmq.read_async(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + def test_send_and_read_msg_with_vt_and_delay(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -154,6 +304,31 @@ def test_send_and_read_msg_with_vt_and_delay(pgmq_setup_teardown: PGMQ_WITH_QUEU assert msg_read.msg_id == msg_id +@pytest.mark.asyncio +async def test_send_and_read_msg_with_vt_and_delay_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_id: int = await pgmq.send_async(queue_name, msg, delay=2) + msg_read = await pgmq.read_async(queue_name, vt=2) + assert msg_read is None + await asyncio.sleep(1) + msg_read = await pgmq.read_async(queue_name, vt=2) + assert msg_read is None + await asyncio.sleep(1.1) + msg_read = await pgmq.read_async(queue_name, vt=2) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + await asyncio.sleep(1.5) + msg_read = await pgmq.read_async(queue_name) + assert msg_read is None + await asyncio.sleep(0.6) + msg_read = await pgmq.read_async(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + def test_send_and_read_msg_with_vt_zero(pgmq_setup_teardown: PGMQ_WITH_QUEUE): """Test that vt=0 works correctly and message becomes visible immediately.""" pgmq, queue_name = pgmq_setup_teardown @@ -169,12 +344,37 @@ def test_send_and_read_msg_with_vt_zero(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert msg_read.msg_id == msg_id +@pytest.mark.asyncio +async def test_send_and_read_msg_with_vt_zero_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + """Test that vt=0 works correctly and message becomes visible immediately.""" + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_id: int = await pgmq.send_async(queue_name, msg) + # Read with vt=0 means message should be immediately visible again + msg_read = await pgmq.read_async(queue_name, vt=0) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + # Message should be visible immediately (no waiting) + msg_read = await pgmq.read_async(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + def test_read_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg_read = pgmq.read(queue_name) assert msg_read is None +@pytest.mark.asyncio +async def test_read_empty_queue_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg_read = await pgmq.read_async(queue_name) + assert msg_read is None + + def test_read_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -188,12 +388,33 @@ def test_read_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert msg_read[1].msg_id == msg_id_2 +@pytest.mark.asyncio +async def test_read_batch_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_id_1: int = await pgmq.send_async(queue_name, msg) + msg_id_2: int = await pgmq.send_async(queue_name, msg) + msg_read = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_read) == 2 + assert msg_read[0].message == msg + assert msg_read[0].msg_id == msg_id_1 + assert msg_read[1].message == msg + assert msg_read[1].msg_id == msg_id_2 + + def test_read_batch_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg_read = pgmq.read_batch(queue_name, 3) assert msg_read is None +@pytest.mark.asyncio +async def test_read_batch_empty_queue_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg_read = await pgmq.read_batch_async(queue_name, 3) + assert msg_read is None + + def test_send_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -202,6 +423,17 @@ def test_send_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert msg_ids == [1, 2, 3] +@pytest.mark.asyncio +async def test_send_batch_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async( + queue_name=queue_name, messages=[msg, msg, msg] + ) + assert len(msg_ids) == 3 + assert msg_ids == [1, 2, 3] + + def test_send_batch_with_read_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -214,6 +446,23 @@ def test_send_batch_with_read_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_read_batch] == [1, 2, 3] +@pytest.mark.asyncio +async def test_send_batch_with_read_batch_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async( + queue_name=queue_name, messages=[msg, msg, msg] + ) + assert len(msg_ids) == 3 + assert msg_ids == [1, 2, 3] + msg_read_batch = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_read_batch) == 3 + assert [msg_read.message for msg_read in msg_read_batch] == [msg, msg, msg] + assert [msg_read.msg_id for msg_read in msg_read_batch] == [1, 2, 3] + + def test_read_with_poll(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -233,6 +482,28 @@ def test_read_with_poll(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert duration < 5 and duration > 2 +@pytest.mark.asyncio +async def test_read_with_poll_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async( + queue_name, [msg, msg, msg, msg, msg], delay=2 + ) + start_time = time.time() + msg_reads = await pgmq.read_with_poll_async( + queue_name, + vt=1000, + qty=3, + max_poll_seconds=5, + poll_interval_ms=1001, + ) + end_time = time.time() + duration = end_time - start_time + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids[:3] + assert duration < 5 and duration > 2 + + def test_read_with_poll_with_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown start_time = time.time() @@ -249,6 +520,25 @@ def test_read_with_poll_with_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert duration > 1.9 +@pytest.mark.asyncio +async def test_read_with_poll_with_empty_queue_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + pgmq, queue_name = async_pgmq_setup_teardown + start_time = time.time() + msg_reads = await pgmq.read_with_poll_async( + queue_name, + vt=1000, + qty=3, + max_poll_seconds=2, + poll_interval_ms=100, + ) + end_time = time.time() + duration = end_time - start_time + assert msg_reads is None + assert duration > 1.9 + + def test_set_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -263,6 +553,21 @@ def test_set_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert msg_read.message == msg +@pytest.mark.asyncio +async def test_set_vt_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_id = await pgmq.send_async(queue_name, msg) + msg_read = await pgmq.set_vt_async(queue_name, msg_id, 2) + assert msg is not None + assert await pgmq.read_async(queue_name) is None + await asyncio.sleep(1.5) + assert await pgmq.read_async(queue_name) is None + await asyncio.sleep(0.6) + msg_read = await pgmq.read_async(queue_name) + assert msg_read.message == msg + + def test_set_vt_to_smaller_value(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -278,12 +583,37 @@ def test_set_vt_to_smaller_value(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert pgmq.read(queue_name) is not None +@pytest.mark.asyncio +async def test_set_vt_to_smaller_value_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_id = await pgmq.send_async(queue_name, msg) + _ = await pgmq.read_async(queue_name, vt=5) # set vt to 5 seconds + assert msg is not None + assert await pgmq.read_async(queue_name) is None + await asyncio.sleep(0.5) + assert await pgmq.set_vt_async(queue_name, msg_id, 1) is not None + await asyncio.sleep(0.3) + assert await pgmq.read_async(queue_name) is None + await asyncio.sleep(0.8) + assert await pgmq.read_async(queue_name) is not None + + def test_set_vt_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg_updated = pgmq.set_vt(queue_name, 999, 20) assert msg_updated is None +@pytest.mark.asyncio +async def test_set_vt_not_exist_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg_updated = await pgmq.set_vt_async(queue_name, 999, 20) + assert msg_updated is None + + def test_pop(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -296,12 +626,32 @@ def test_pop(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids[1:] +@pytest.mark.asyncio +async def test_pop_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + msg = await pgmq.pop_async(queue_name) + assert msg.msg_id == msg_ids[0] + assert msg.message == MSG + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 2 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids[1:] + + def test_pop_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = pgmq.pop(queue_name) assert msg is None +@pytest.mark.asyncio +async def test_pop_empty_queue_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = await pgmq.pop_async(queue_name) + assert msg is None + + def test_delete_msg(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -312,6 +662,17 @@ def test_delete_msg(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[0], msg_ids[2]] +@pytest.mark.asyncio +async def test_delete_msg_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.delete_async(queue_name, msg_ids[1]) is True + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 2 + assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[0], msg_ids[2]] + + def test_delete_msg_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -322,6 +683,17 @@ def test_delete_msg_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids +@pytest.mark.asyncio +async def test_delete_msg_not_exist_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.delete_async(queue_name, 999) is False + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids + + def test_delete_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -335,6 +707,20 @@ def test_delete_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1]] +@pytest.mark.asyncio +async def test_delete_batch_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.delete_batch_async(queue_name, [msg_ids[0], msg_ids[2]]) == [ + msg_ids[0], + msg_ids[2], + ] + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 1 + assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1]] + + def test_delete_batch_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -345,6 +731,17 @@ def test_delete_batch_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids +@pytest.mark.asyncio +async def test_delete_batch_not_exist_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.delete_batch_async(queue_name, [999, 998]) == [] + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids + + def test_archive(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -355,6 +752,17 @@ def test_archive(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1], msg_ids[2]] +@pytest.mark.asyncio +async def test_archive_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.archive_async(queue_name, msg_ids[0]) is True + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 2 + assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1], msg_ids[2]] + + def test_archive_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -365,6 +773,17 @@ def test_archive_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids +@pytest.mark.asyncio +async def test_archive_not_exist_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.archive_async(queue_name, 999) is False + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids + + def test_archive_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -378,6 +797,20 @@ def test_archive_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1]] +@pytest.mark.asyncio +async def test_archive_batch_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.archive_batch_async(queue_name, [msg_ids[0], msg_ids[2]]) == [ + msg_ids[0], + msg_ids[2], + ] + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 1 + assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1]] + + def test_archive_batch_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -388,6 +821,19 @@ def test_archive_batch_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids +@pytest.mark.asyncio +async def test_archive_batch_not_exist_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + msg_ids = await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.archive_batch_async(queue_name, [999, 998]) == [] + msg_reads = await pgmq.read_batch_async(queue_name, 3) + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids + + def test_purge(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown msg = MSG @@ -396,6 +842,15 @@ def test_purge(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert pgmq.purge(queue_name) == 3 +@pytest.mark.asyncio +async def test_purge_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + msg = MSG + assert await pgmq.purge_async(queue_name) == 0 + await pgmq.send_batch_async(queue_name, [msg, msg, msg]) + assert await pgmq.purge_async(queue_name) == 3 + + def test_metrics(pgmq_setup_teardown: PGMQ_WITH_QUEUE): pgmq, queue_name = pgmq_setup_teardown metrics = pgmq.metrics(queue_name) @@ -407,6 +862,18 @@ def test_metrics(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert metrics.total_messages == 0 +@pytest.mark.asyncio +async def test_metrics_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = async_pgmq_setup_teardown + metrics = await pgmq.metrics_async(queue_name) + assert metrics is not None + assert metrics.queue_name == queue_name + assert metrics.queue_length == 0 + assert metrics.newest_msg_age_sec is None + assert metrics.oldest_msg_age_sec is None + assert metrics.total_messages == 0 + + def test_metrics_all_queues(pgmq_setup_teardown: PGMQ_WITH_QUEUE): # Since default PostgreSQL isolation level is `READ COMMITTED`, # pytest-xdist is running in **muti-process** mode, which causes **Phantom read** ! @@ -427,6 +894,27 @@ def test_metrics_all_queues(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert queue_2.total_messages == 2 +@pytest.mark.asyncio +async def test_metrics_all_queues_async(async_pgmq_setup_teardown: PGMQ_WITH_QUEUE): + # Since default PostgreSQL isolation level is `READ COMMITTED`, + # pytest-xdist is running in **muti-process** mode, which causes **Phantom read** ! + # - `pgmq.metrics_all()` will first get the queue list, then get the metrics for each queue + # - If another process teardown the queue before the metrics are fetched, will throw an exception that the `{queue_name}` does not exist + with FileLock(LOCK_FILE_NAME): + pgmq, queue_name_1 = async_pgmq_setup_teardown + queue_name_2 = f"test_queue_{uuid.uuid4().hex}" + await pgmq.create_queue_async(queue_name_2) + await pgmq.send_batch_async(queue_name_1, [MSG, MSG, MSG]) + await pgmq.send_batch_async(queue_name_2, [MSG, MSG]) + metrics_all = await pgmq.metrics_all_async() + queue_1 = [q for q in metrics_all if q.queue_name == queue_name_1][0] + queue_2 = [q for q in metrics_all if q.queue_name == queue_name_2][0] + assert queue_1.queue_length == 3 + assert queue_2.queue_length == 2 + assert queue_1.total_messages == 3 + assert queue_2.total_messages == 2 + + # Tests for time-based partitioned queues def test_create_time_based_partitioned_queue(pgmq_all_variants, db_session): pgmq: PGMQueue = pgmq_all_variants @@ -437,6 +925,19 @@ def test_create_time_based_partitioned_queue(pgmq_all_variants, db_session): assert check_queue_exists(db_session, queue_name) is True +# Tests for time-based partitioned queues +@pytest.mark.asyncio +async def test_create_time_based_partitioned_queue_async( + pgmq_all_variants, async_db_session +): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + await pgmq.create_partitioned_queue_async( + queue_name, partition_interval="1 day", retention_interval="7 days" + ) + assert await check_queue_exists_async(async_db_session, queue_name) is True + + def test_create_time_based_partitioned_queue_various_intervals( pgmq_all_variants, db_session ): @@ -457,6 +958,27 @@ def test_create_time_based_partitioned_queue_various_intervals( assert check_queue_exists(db_session, queue_name_week) is True +@pytest.mark.asyncio +async def test_create_time_based_partitioned_queue_various_intervals_async( + pgmq_all_variants, async_db_session +): + pgmq: PGMQueue = pgmq_all_variants + + # Test with hour + queue_name_hour = f"test_queue_{uuid.uuid4().hex}" + await pgmq.create_partitioned_queue_async( + queue_name_hour, partition_interval="1 hour", retention_interval="24 hours" + ) + assert await check_queue_exists_async(async_db_session, queue_name_hour) is True + + # Test with week + queue_name_week = f"test_queue_{uuid.uuid4().hex}" + await pgmq.create_partitioned_queue_async( + queue_name_week, partition_interval="1 week", retention_interval="4 weeks" + ) + assert await check_queue_exists_async(async_db_session, queue_name_week) is True + + def test_create_partitioned_queue_invalid_time_interval(pgmq_all_variants): pgmq: PGMQueue = pgmq_all_variants queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -469,6 +991,19 @@ def test_create_partitioned_queue_invalid_time_interval(pgmq_all_variants): assert "Invalid time-based partition interval" in str(e.value) +@pytest.mark.asyncio +async def test_create_partitioned_queue_invalid_time_interval_async(pgmq_all_variants): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + with pytest.raises(ValueError) as e: + await pgmq.create_partitioned_queue_async( + queue_name, + partition_interval="invalid interval", + retention_interval="7 days", + ) + assert "Invalid time-based partition interval" in str(e.value) + + def test_create_partitioned_queue_invalid_numeric_interval(pgmq_all_variants): pgmq: PGMQueue = pgmq_all_variants queue_name = f"test_queue_{uuid.uuid4().hex}" @@ -479,6 +1014,19 @@ def test_create_partitioned_queue_invalid_numeric_interval(pgmq_all_variants): assert "Numeric partition interval must be positive" in str(e.value) +@pytest.mark.asyncio +async def test_create_partitioned_queue_invalid_numeric_interval_async( + pgmq_all_variants, +): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + with pytest.raises(ValueError) as e: + await pgmq.create_partitioned_queue_async( + queue_name, partition_interval=-100, retention_interval=100000 + ) + assert "Numeric partition interval must be positive" in str(e.value) + + def test_read_with_poll_without_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): """Test read_with_poll when vt parameter is not provided (None).""" @@ -506,6 +1054,36 @@ def test_read_with_poll_without_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): assert msgs[0].message == MSG +@pytest.mark.asyncio +async def test_read_with_poll_without_vt_async( + async_pgmq_setup_teardown: PGMQ_WITH_QUEUE, +): + """Test read_with_poll when vt parameter is not provided (None).""" + + pgmq, queue_name = async_pgmq_setup_teardown + + # Set a custom default vt for the pgmq instance + pgmq.vt = 100 + + # Send a message + msg_id = await pgmq.send_async(queue_name, MSG) + + # Call read_with_poll with vt=None to test the fallback logic + # When vt is None, it should fall back to using pgmq.vt value (100) + msgs = await pgmq.read_with_poll_async( + queue_name, + vt=None, # Explicitly passing None to test the None fallback logic + qty=1, + max_poll_seconds=2, + poll_interval_ms=100, + ) + + assert msgs is not None + assert len(msgs) == 1 + assert msgs[0].msg_id == msg_id + assert msgs[0].message == MSG + + def test_execute_operation_with_provided_sync_session( pgmq_by_session_maker, get_session_maker, db_session ): @@ -539,3 +1117,39 @@ def test_execute_operation_with_provided_sync_session( # Verify queue was dropped assert check_queue_exists(db_session, queue_name) is False + + +@pytest.mark.asyncio +async def test_execute_operation_with_provided_sync_session_async( + pgmq_by_session_maker, get_async_session_maker, async_db_session +): + """Test _execute_operation sync path when session is provided.""" + + pgmq: PGMQueue = pgmq_by_session_maker + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create a session to pass to the operations + # Using the same session across multiple operations demonstrates + # that the sync path with provided session works correctly + async with get_async_session_maker() as session: + # Create queue with provided session + await pgmq.create_queue_async(queue_name, session=session) + + # Verify queue was created + assert await check_queue_exists_async(async_db_session, queue_name) is True + + # Send a message with the same provided session + msg_id = await pgmq.send_async(queue_name, MSG, session=session) + + # Read message with the same provided session + msg = await pgmq.read_async(queue_name, vt=30, session=session) + + assert msg is not None + assert msg.msg_id == msg_id + assert msg.message == MSG + + # Clean up with the same provided session + await pgmq.drop_queue_async(queue_name, session=session) + + # Verify queue was dropped + assert await check_queue_exists_async(async_db_session, queue_name) is False diff --git a/tests/test_queue_backup.py b/tests/test_queue_backup.py new file mode 100644 index 0000000..e4956f7 --- /dev/null +++ b/tests/test_queue_backup.py @@ -0,0 +1,541 @@ +import uuid +import pytest +import time + +from sqlalchemy.exc import ProgrammingError +from filelock import FileLock +from pgmq_sqlalchemy import PGMQueue + +from tests.fixture_deps import ( + PGMQ_WITH_QUEUE, + pgmq_setup_teardown, + pgmq_partitioned_setup_teardown, + pgmq_all_variants, +) + +from tests._utils import check_queue_exists +from tests.constant import MSG, LOCK_FILE_NAME + +use_fixtures = [pgmq_setup_teardown, pgmq_partitioned_setup_teardown, pgmq_all_variants] + + +def test_create_queue(pgmq_all_variants, db_session): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + pgmq.create_queue(queue_name) + assert check_queue_exists(db_session, queue_name) is True + + +def test_create_partitioned_queue(pgmq_all_variants, db_session): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + pgmq.create_partitioned_queue(queue_name) + assert check_queue_exists(db_session, queue_name) is True + + +def test_create_same_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE, db_session): + pgmq, queue_name = pgmq_setup_teardown + pgmq.create_queue(queue_name) + assert check_queue_exists(db_session, queue_name) is True + pgmq.create_queue(queue_name) + # `create_queue` with the same queue name should not raise an exception + # and the queue should still exist + assert check_queue_exists(db_session, queue_name) is True + + +def test_validate_queue_name(pgmq_all_variants): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + pgmq.validate_queue_name(queue_name) + # `queue_name` should be a less than 48 characters + with pytest.raises(Exception) as e: + pgmq.validate_queue_name("a" * 49) + error_msg: str = str(e.value.orig) + assert "queue name is too long, maximum length is 48 characters" in error_msg + + +def test_drop_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + _ = pgmq_setup_teardown + pass + + +def test_drop_non_exist_queue(pgmq_all_variants, db_session): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + assert check_queue_exists(db_session, queue_name) is False + with pytest.raises(ProgrammingError): + pgmq.drop_queue(queue_name) + + +def test_drop_partitioned_queue(pgmq_partitioned_setup_teardown: PGMQ_WITH_QUEUE): + _ = pgmq_partitioned_setup_teardown + pass + + +def test_drop_non_exist_partitioned_queue(pgmq_all_variants, db_session): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + assert check_queue_exists(db_session, queue_name) is False + with pytest.raises(ProgrammingError): + pgmq.drop_queue(queue_name, partitioned=True) + + +def test_list_queues(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + queues = pgmq.list_queues() + assert queue_name in queues + + +def test_list_partitioned_queues(pgmq_partitioned_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_partitioned_setup_teardown + queues = pgmq.list_queues() + assert queue_name in queues + + +def test_send_and_read_msg(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_id: int = pgmq.send(queue_name, msg) + msg_read = pgmq.read(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + +def test_send_and_read_msg_with_delay(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_id: int = pgmq.send(queue_name, msg, delay=2) + msg_read = pgmq.read(queue_name) + assert msg_read is None + time.sleep(1) + msg_read = pgmq.read(queue_name) + assert msg_read is None + time.sleep(1.1) + msg_read = pgmq.read(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + +def test_send_and_read_msg_with_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_id: int = pgmq.send(queue_name, msg) + msg_read = pgmq.read(queue_name, vt=2) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + time.sleep(1.5) + msg_read = pgmq.read(queue_name) + assert msg_read is None + time.sleep(0.6) + msg_read = pgmq.read(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + +def test_send_and_read_msg_with_vt_and_delay(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_id: int = pgmq.send(queue_name, msg, delay=2) + msg_read = pgmq.read(queue_name, vt=2) + assert msg_read is None + time.sleep(1) + msg_read = pgmq.read(queue_name, vt=2) + assert msg_read is None + time.sleep(1.1) + msg_read = pgmq.read(queue_name, vt=2) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + time.sleep(1.5) + msg_read = pgmq.read(queue_name) + assert msg_read is None + time.sleep(0.6) + msg_read = pgmq.read(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + +def test_send_and_read_msg_with_vt_zero(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + """Test that vt=0 works correctly and message becomes visible immediately.""" + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_id: int = pgmq.send(queue_name, msg) + # Read with vt=0 means message should be immediately visible again + msg_read = pgmq.read(queue_name, vt=0) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + # Message should be visible immediately (no waiting) + msg_read = pgmq.read(queue_name) + assert msg_read.message == msg + assert msg_read.msg_id == msg_id + + +def test_read_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg_read = pgmq.read(queue_name) + assert msg_read is None + + +def test_read_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_id_1: int = pgmq.send(queue_name, msg) + msg_id_2: int = pgmq.send(queue_name, msg) + msg_read = pgmq.read_batch(queue_name, 3) + assert len(msg_read) == 2 + assert msg_read[0].message == msg + assert msg_read[0].msg_id == msg_id_1 + assert msg_read[1].message == msg + assert msg_read[1].msg_id == msg_id_2 + + +def test_read_batch_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg_read = pgmq.read_batch(queue_name, 3) + assert msg_read is None + + +def test_send_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name=queue_name, messages=[msg, msg, msg]) + assert len(msg_ids) == 3 + assert msg_ids == [1, 2, 3] + + +def test_send_batch_with_read_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name=queue_name, messages=[msg, msg, msg]) + assert len(msg_ids) == 3 + assert msg_ids == [1, 2, 3] + msg_read_batch = pgmq.read_batch(queue_name, 3) + assert len(msg_read_batch) == 3 + assert [msg_read.message for msg_read in msg_read_batch] == [msg, msg, msg] + assert [msg_read.msg_id for msg_read in msg_read_batch] == [1, 2, 3] + + +def test_read_with_poll(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg, msg, msg], delay=2) + start_time = time.time() + msg_reads = pgmq.read_with_poll( + queue_name, + vt=1000, + qty=3, + max_poll_seconds=5, + poll_interval_ms=1001, + ) + end_time = time.time() + duration = end_time - start_time + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids[:3] + assert duration < 5 and duration > 2 + + +def test_read_with_poll_with_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + start_time = time.time() + msg_reads = pgmq.read_with_poll( + queue_name, + vt=1000, + qty=3, + max_poll_seconds=2, + poll_interval_ms=100, + ) + end_time = time.time() + duration = end_time - start_time + assert msg_reads is None + assert duration > 1.9 + + +def test_set_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_id = pgmq.send(queue_name, msg) + msg_read = pgmq.set_vt(queue_name, msg_id, 2) + assert msg is not None + assert pgmq.read(queue_name) is None + time.sleep(1.5) + assert pgmq.read(queue_name) is None + time.sleep(0.6) + msg_read = pgmq.read(queue_name) + assert msg_read.message == msg + + +def test_set_vt_to_smaller_value(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_id = pgmq.send(queue_name, msg) + _ = pgmq.read(queue_name, vt=5) # set vt to 5 seconds + assert msg is not None + assert pgmq.read(queue_name) is None + time.sleep(0.5) + assert pgmq.set_vt(queue_name, msg_id, 1) is not None + time.sleep(0.3) + assert pgmq.read(queue_name) is None + time.sleep(0.8) + assert pgmq.read(queue_name) is not None + + +def test_set_vt_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg_updated = pgmq.set_vt(queue_name, 999, 20) + assert msg_updated is None + + +def test_pop(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + msg = pgmq.pop(queue_name) + assert msg.msg_id == msg_ids[0] + assert msg.message == MSG + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 2 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids[1:] + + +def test_pop_empty_queue(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = pgmq.pop(queue_name) + assert msg is None + + +def test_delete_msg(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.delete(queue_name, msg_ids[1]) is True + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 2 + assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[0], msg_ids[2]] + + +def test_delete_msg_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.delete(queue_name, 999) is False + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids + + +def test_delete_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.delete_batch(queue_name, [msg_ids[0], msg_ids[2]]) == [ + msg_ids[0], + msg_ids[2], + ] + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 1 + assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1]] + + +def test_delete_batch_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.delete_batch(queue_name, [999, 998]) == [] + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids + + +def test_archive(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.archive(queue_name, msg_ids[0]) is True + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 2 + assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1], msg_ids[2]] + + +def test_archive_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.archive(queue_name, 999) is False + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids + + +def test_archive_batch(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.archive_batch(queue_name, [msg_ids[0], msg_ids[2]]) == [ + msg_ids[0], + msg_ids[2], + ] + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 1 + assert [msg_read.msg_id for msg_read in msg_reads] == [msg_ids[1]] + + +def test_archive_batch_not_exist(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + msg_ids = pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.archive_batch(queue_name, [999, 998]) == [] + msg_reads = pgmq.read_batch(queue_name, 3) + assert len(msg_reads) == 3 + assert [msg_read.msg_id for msg_read in msg_reads] == msg_ids + + +def test_purge(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + msg = MSG + assert pgmq.purge(queue_name) == 0 + pgmq.send_batch(queue_name, [msg, msg, msg]) + assert pgmq.purge(queue_name) == 3 + + +def test_metrics(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + pgmq, queue_name = pgmq_setup_teardown + metrics = pgmq.metrics(queue_name) + assert metrics is not None + assert metrics.queue_name == queue_name + assert metrics.queue_length == 0 + assert metrics.newest_msg_age_sec is None + assert metrics.oldest_msg_age_sec is None + assert metrics.total_messages == 0 + + +def test_metrics_all_queues(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + # Since default PostgreSQL isolation level is `READ COMMITTED`, + # pytest-xdist is running in **muti-process** mode, which causes **Phantom read** ! + # - `pgmq.metrics_all()` will first get the queue list, then get the metrics for each queue + # - If another process teardown the queue before the metrics are fetched, will throw an exception that the `{queue_name}` does not exist + with FileLock(LOCK_FILE_NAME): + pgmq, queue_name_1 = pgmq_setup_teardown + queue_name_2 = f"test_queue_{uuid.uuid4().hex}" + pgmq.create_queue(queue_name_2) + pgmq.send_batch(queue_name_1, [MSG, MSG, MSG]) + pgmq.send_batch(queue_name_2, [MSG, MSG]) + metrics_all = pgmq.metrics_all() + queue_1 = [q for q in metrics_all if q.queue_name == queue_name_1][0] + queue_2 = [q for q in metrics_all if q.queue_name == queue_name_2][0] + assert queue_1.queue_length == 3 + assert queue_2.queue_length == 2 + assert queue_1.total_messages == 3 + assert queue_2.total_messages == 2 + + +# Tests for time-based partitioned queues +def test_create_time_based_partitioned_queue(pgmq_all_variants, db_session): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + pgmq.create_partitioned_queue( + queue_name, partition_interval="1 day", retention_interval="7 days" + ) + assert check_queue_exists(db_session, queue_name) is True + + +def test_create_time_based_partitioned_queue_various_intervals( + pgmq_all_variants, db_session +): + pgmq: PGMQueue = pgmq_all_variants + + # Test with hour + queue_name_hour = f"test_queue_{uuid.uuid4().hex}" + pgmq.create_partitioned_queue( + queue_name_hour, partition_interval="1 hour", retention_interval="24 hours" + ) + assert check_queue_exists(db_session, queue_name_hour) is True + + # Test with week + queue_name_week = f"test_queue_{uuid.uuid4().hex}" + pgmq.create_partitioned_queue( + queue_name_week, partition_interval="1 week", retention_interval="4 weeks" + ) + assert check_queue_exists(db_session, queue_name_week) is True + + +def test_create_partitioned_queue_invalid_time_interval(pgmq_all_variants): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + with pytest.raises(ValueError) as e: + pgmq.create_partitioned_queue( + queue_name, + partition_interval="invalid interval", + retention_interval="7 days", + ) + assert "Invalid time-based partition interval" in str(e.value) + + +def test_create_partitioned_queue_invalid_numeric_interval(pgmq_all_variants): + pgmq: PGMQueue = pgmq_all_variants + queue_name = f"test_queue_{uuid.uuid4().hex}" + with pytest.raises(ValueError) as e: + pgmq.create_partitioned_queue( + queue_name, partition_interval=-100, retention_interval=100000 + ) + assert "Numeric partition interval must be positive" in str(e.value) + + +def test_read_with_poll_without_vt(pgmq_setup_teardown: PGMQ_WITH_QUEUE): + """Test read_with_poll when vt parameter is not provided (None).""" + + pgmq, queue_name = pgmq_setup_teardown + + # Set a custom default vt for the pgmq instance + pgmq.vt = 100 + + # Send a message + msg_id = pgmq.send(queue_name, MSG) + + # Call read_with_poll with vt=None to test the fallback logic + # When vt is None, it should fall back to using pgmq.vt value (100) + msgs = pgmq.read_with_poll( + queue_name, + vt=None, # Explicitly passing None to test the None fallback logic + qty=1, + max_poll_seconds=2, + poll_interval_ms=100, + ) + + assert msgs is not None + assert len(msgs) == 1 + assert msgs[0].msg_id == msg_id + assert msgs[0].message == MSG + + +def test_execute_operation_with_provided_sync_session( + pgmq_by_session_maker, get_session_maker, db_session +): + """Test _execute_operation sync path when session is provided.""" + + pgmq: PGMQueue = pgmq_by_session_maker + queue_name = f"test_queue_{uuid.uuid4().hex}" + + # Create a session to pass to the operations + # Using the same session across multiple operations demonstrates + # that the sync path with provided session works correctly + with get_session_maker() as session: + # Create queue with provided session + pgmq.create_queue(queue_name, session=session) + + # Verify queue was created + assert check_queue_exists(db_session, queue_name) is True + + # Send a message with the same provided session + msg_id = pgmq.send(queue_name, MSG, session=session) + + # Read message with the same provided session + msg = pgmq.read(queue_name, vt=30, session=session) + + assert msg is not None + assert msg.msg_id == msg_id + assert msg.message == MSG + + # Clean up with the same provided session + pgmq.drop_queue(queue_name, session=session) + + # Verify queue was dropped + assert check_queue_exists(db_session, queue_name) is False diff --git a/uv.lock b/uv.lock index fa07dc9..0d496df 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,9 @@ version = 1 revision = 1 requires-python = ">=3.9" resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", "python_full_version < '3.10'", ] @@ -24,7 +26,9 @@ name = "alabaster" version = "1.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210 } @@ -368,7 +372,9 @@ name = "click" version = "8.3.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] dependencies = [ @@ -512,7 +518,9 @@ name = "coverage" version = "7.13.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905 } @@ -662,7 +670,9 @@ name = "filelock" version = "3.20.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476 } @@ -751,7 +761,9 @@ name = "greenlet" version = "3.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651 } @@ -861,7 +873,9 @@ name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } @@ -881,6 +895,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/52/97d5454dee9d014821fe0c88f3dc0e83131b97dd074a4d49537056a75475/libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9", size = 2211698 }, + { url = "https://files.pythonhosted.org/packages/6c/a4/d1205985d378164687af3247a9c8f8bdb96278b0686ac98ab951bc6d336a/libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6", size = 2093104 }, + { url = "https://files.pythonhosted.org/packages/9e/de/1338da681b7625b51e584922576d54f1b8db8fc7ff4dc79121afc5d4d2cd/libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58", size = 2237419 }, + { url = "https://files.pythonhosted.org/packages/50/06/ee66f2d83b870534756e593d464d8b33b0914c224dff3a407e0f74dc04e0/libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8", size = 2300820 }, + { url = "https://files.pythonhosted.org/packages/9c/ca/959088729de8e0eac8dd516e4fb8623d8d92bad539060fa85c9e94d418a5/libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba", size = 2301201 }, + { url = "https://files.pythonhosted.org/packages/c2/4c/2a21a8c452436097dfe1da277f738c3517f3f728713f16d84b9a3d67ca8d/libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b", size = 2408213 }, + { url = "https://files.pythonhosted.org/packages/3e/26/8f7b671fad38a515bb20b038718fd2221ab658299119ac9bcec56c2ced27/libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073", size = 2119189 }, + { url = "https://files.pythonhosted.org/packages/5b/bf/ffb23a48e27001165cc5c81c5d9b3d6583b21b7f5449109e03a0020b060c/libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100", size = 2001736 }, + { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289 }, + { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927 }, + { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002 }, + { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048 }, + { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675 }, + { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934 }, + { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247 }, + { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774 }, + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166 }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726 }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755 }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473 }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899 }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239 }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660 }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824 }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386 }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945 }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818 }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289 }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230 }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519 }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853 }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808 }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553 }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717 }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834 }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107 }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672 }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661 }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068 }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181 }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202 }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581 }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495 }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466 }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264 }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572 }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917 }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748 }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980 }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828 }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568 }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523 }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044 }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605 }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581 }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000 }, + { url = "https://files.pythonhosted.org/packages/0c/09/69a0cd1eeb358f03c3ccd79ca22778afc1c1c723158270ad84ce86266eed/libcst-1.8.6-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cb2679ef532f9fa5be5c5a283b6357cb6e9888a8dd889c4bb2b01845a29d8c0b", size = 2211812 }, + { url = "https://files.pythonhosted.org/packages/ff/38/b965fa7bc4409520404261ce6bdf019e56bed1674b9a68ddfc9e25bc904c/libcst-1.8.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:203ec2a83f259baf686b9526268cd23d048d38be5589594ef143aee50a4faf7e", size = 2093137 }, + { url = "https://files.pythonhosted.org/packages/a9/7c/083084b91db049343c49a27279c226f4eb27d28bef4942965386418e643e/libcst-1.8.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6366ab2107425bf934b0c83311177f2a371bfc757ee8c6ad4a602d7cbcc2f363", size = 2237609 }, + { url = "https://files.pythonhosted.org/packages/26/c5/fcf60600a809b9e4cf75e82484a7a9a4bdc80ba3c9939a6a18af3379c6c7/libcst-1.8.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6aa11df6c58812f731172b593fcb485d7ba09ccc3b52fea6c7f26a43377dc748", size = 2301394 }, + { url = "https://files.pythonhosted.org/packages/9f/73/d72942eb3f520bc9444e61a48236694dee3cdc13f6b59179e5288d725b93/libcst-1.8.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:351ab879c2fd20d9cb2844ed1ea3e617ed72854d3d1e2b0880ede9c3eea43ba8", size = 2301816 }, + { url = "https://files.pythonhosted.org/packages/03/a9/5732b20569a434ee3ff96f1b263e6e3f3df70d8dba5cf7c8f7d4b1d6aa41/libcst-1.8.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98fa1ca321c81fb1f02e5c43f956ca543968cc1a30b264fd8e0a2e1b0b0bf106", size = 2408392 }, + { url = "https://files.pythonhosted.org/packages/f9/ad/ecb1275796504a34a9d6d5d4f73bd81cb12930064e98871ad4b4042b82e1/libcst-1.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:25fc7a1303cad7639ad45ec38c06789b4540b7258e9a108924aaa2c132af4aca", size = 2119206 }, + { url = "https://files.pythonhosted.org/packages/94/32/b6521d32a7cde089380efa948e05a7cff95c7ece8f7c36380dd6b4bf2263/libcst-1.8.6-cp39-cp39-win_arm64.whl", hash = "sha256:4d7bbdd35f3abdfb5ac5d1a674923572dab892b126a58da81ff2726102d6ec2e", size = 2001882 }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1019,6 +1110,7 @@ dev = [ { name = "filelock", version = "3.20.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "greenlet", version = "3.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "greenlet", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "libcst" }, { name = "pg8000" }, { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "psycopg", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -1058,6 +1150,7 @@ requires-dist = [ { name = "filelock", marker = "extra == 'dev'", specifier = ">=3.15.4" }, { name = "greenlet", marker = "extra == 'asyncpg'", specifier = ">=3.0.3" }, { name = "greenlet", marker = "extra == 'dev'", specifier = ">=3.0.3" }, + { name = "libcst", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "pg8000", marker = "extra == 'dev'", specifier = ">=1.31.2" }, { name = "pg8000", marker = "extra == 'pg8000'", specifier = ">=1.31.2" }, { name = "psycopg", marker = "extra == 'dev'", specifier = ">=3.2.1" }, @@ -1109,7 +1202,9 @@ name = "psycopg" version = "3.3.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] dependencies = [ @@ -1305,6 +1400,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450 }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319 }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631 }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795 }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767 }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982 }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677 }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592 }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777 }, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027 }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146 }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792 }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772 }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723 }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478 }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159 }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779 }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331 }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879 }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277 }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650 }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755 }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403 }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581 }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579 }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1439,7 +1631,9 @@ name = "sphinx" version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", ] dependencies = [ { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -1494,7 +1688,9 @@ name = "sphinx-autobuild" version = "2025.8.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.11'" }, @@ -1685,7 +1881,9 @@ name = "starlette" version = "0.50.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] dependencies = [ @@ -1795,7 +1993,9 @@ name = "uvicorn" version = "0.40.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version == '3.10.*'", ] dependencies = [