From ec121f2e9480774444b63b1b35a49239e8e030e2 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 8 Nov 2025 19:35:52 +0300 Subject: [PATCH] rename to db-try, sync with internal version --- .github/workflows/ci.yml | 2 +- Justfile | 2 +- README.md | 4 +- db_try/__init__.py | 13 +++++ {modern_pg => db_try}/connections.py | 2 +- db_try/decorators.py | 42 ++++++++++++++++ {modern_pg => db_try}/helpers.py | 0 {modern_pg => db_try}/py.typed | 0 db_try/settings.py | 5 ++ db_try/transaction.py | 28 +++++++++++ modern_pg/__init__.py | 14 ------ modern_pg/decorators.py | 72 ---------------------------- modern_pg/settings.py | 6 --- modern_pg/transaction.py | 19 -------- pyproject.toml | 24 +++++----- tests/test_connection_factory.py | 2 +- tests/test_decorators.py | 39 ++------------- tests/test_helpers.py | 2 +- tests/test_transaction.py | 22 ++++++++- 19 files changed, 133 insertions(+), 165 deletions(-) create mode 100644 db_try/__init__.py rename {modern_pg => db_try}/connections.py (98%) create mode 100644 db_try/decorators.py rename {modern_pg => db_try}/helpers.py (100%) rename {modern_pg => db_try}/py.typed (100%) create mode 100644 db_try/settings.py create mode 100644 db_try/transaction.py delete mode 100644 modern_pg/__init__.py delete mode 100644 modern_pg/decorators.py delete mode 100644 modern_pg/settings.py delete mode 100644 modern_pg/transaction.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a26d4c1..c71a1ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" - - run: uv python install 3.10 + - run: uv python install 3.13 - run: just install lint-ci pytest: diff --git a/Justfile b/Justfile index e0196d6..f6d85ab 100644 --- a/Justfile +++ b/Justfile @@ -14,7 +14,7 @@ build: install: uv lock --upgrade - uv sync --all-extras --frozen + uv sync --all-extras --all-groups --frozen lint: uv run --frozen ruff format diff --git a/README.md b/README.md index 2bedd6c..b84df0c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# modern-pg +# db-try This library provides retry decorators for sqlalchemy and some helpers -Default settings are in [./pg-tools/settings.py](modern_pg/settings.py). +Default settings are in [./db_try/settings.py](db_try/settings.py). You can redefine them by environment variables. diff --git a/db_try/__init__.py b/db_try/__init__.py new file mode 100644 index 0000000..b4e7981 --- /dev/null +++ b/db_try/__init__.py @@ -0,0 +1,13 @@ +from db_try.connections import build_connection_factory +from db_try.decorators import postgres_retry +from db_try.helpers import build_db_dsn, is_dsn_multihost +from db_try.transaction import Transaction + + +__all__ = [ + "Transaction", + "build_connection_factory", + "build_db_dsn", + "is_dsn_multihost", + "postgres_retry", +] diff --git a/modern_pg/connections.py b/db_try/connections.py similarity index 98% rename from modern_pg/connections.py rename to db_try/connections.py index db2082f..d02a7e9 100644 --- a/modern_pg/connections.py +++ b/db_try/connections.py @@ -71,7 +71,7 @@ async def _connection_factory() -> "ConnectionType": target_session_attrs=target_session_attrs, ) return connection # noqa: TRY300 - except (TimeoutError, OSError, asyncpg.TargetServerAttributeNotMatched) as exc: # noqa: PERF203 + except (TimeoutError, OSError, asyncpg.TargetServerAttributeNotMatched) as exc: logger.warning("Failed to fetch asyncpg connection from %s, %s", one_host, exc) msg: typing.Final = f"None of the hosts match the target attribute requirement {target_session_attrs}" raise asyncpg.TargetServerAttributeNotMatched(msg) diff --git a/db_try/decorators.py b/db_try/decorators.py new file mode 100644 index 0000000..890021b --- /dev/null +++ b/db_try/decorators.py @@ -0,0 +1,42 @@ +import functools +import logging +import typing + +import asyncpg +import tenacity +from sqlalchemy.exc import DBAPIError + +from db_try import settings + + +logger = logging.getLogger(__name__) + + +def _retry_handler(exception: BaseException) -> bool: + if ( + isinstance(exception, DBAPIError) + and hasattr(exception, "orig") + and isinstance(exception.orig.__cause__, (asyncpg.SerializationError, asyncpg.PostgresConnectionError)) # type: ignore[union-attr] + ): + logger.debug("postgres_retry, retrying") + return True + + logger.debug("postgres_retry, giving up on retry") + return False + + +def postgres_retry[**P, T]( + func: typing.Callable[P, typing.Coroutine[None, None, T]], +) -> typing.Callable[P, typing.Coroutine[None, None, T]]: + @tenacity.retry( + stop=tenacity.stop_after_attempt(settings.DB_UTILS_RETRIES_NUMBER), + wait=tenacity.wait_exponential_jitter(), + retry=tenacity.retry_if_exception(_retry_handler), + reraise=True, + before=tenacity.before_log(logger, logging.DEBUG), + ) + @functools.wraps(func) + async def wrapped_method(*args: P.args, **kwargs: P.kwargs) -> T: + return await func(*args, **kwargs) + + return wrapped_method diff --git a/modern_pg/helpers.py b/db_try/helpers.py similarity index 100% rename from modern_pg/helpers.py rename to db_try/helpers.py diff --git a/modern_pg/py.typed b/db_try/py.typed similarity index 100% rename from modern_pg/py.typed rename to db_try/py.typed diff --git a/db_try/settings.py b/db_try/settings.py new file mode 100644 index 0000000..d757079 --- /dev/null +++ b/db_try/settings.py @@ -0,0 +1,5 @@ +import os +import typing + + +DB_UTILS_RETRIES_NUMBER: typing.Final = int(os.getenv("DB_UTILS_RETRIES_NUMBER", "3")) diff --git a/db_try/transaction.py b/db_try/transaction.py new file mode 100644 index 0000000..c6f422d --- /dev/null +++ b/db_try/transaction.py @@ -0,0 +1,28 @@ +import dataclasses + +import typing_extensions +from sqlalchemy.engine.interfaces import IsolationLevel +from sqlalchemy.ext import asyncio as sa_async + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class Transaction: + session: sa_async.AsyncSession + isolation_level: IsolationLevel | None = None + + async def __aenter__(self) -> typing_extensions.Self: + if self.isolation_level: + await self.session.connection(execution_options={"isolation_level": self.isolation_level}) + + if not self.session.in_transaction(): + await self.session.begin() + return self + + async def __aexit__(self, *args: object, **kwargs: object) -> None: + await self.session.close() + + async def commit(self) -> None: + await self.session.commit() + + async def rollback(self) -> None: + await self.session.rollback() diff --git a/modern_pg/__init__.py b/modern_pg/__init__.py deleted file mode 100644 index 91431ee..0000000 --- a/modern_pg/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from modern_pg.connections import build_connection_factory -from modern_pg.decorators import postgres_reconnect, transaction_retry -from modern_pg.helpers import build_db_dsn, is_dsn_multihost -from modern_pg.transaction import Transaction - - -__all__ = [ - "Transaction", - "build_connection_factory", - "build_db_dsn", - "is_dsn_multihost", - "postgres_reconnect", - "transaction_retry", -] diff --git a/modern_pg/decorators.py b/modern_pg/decorators.py deleted file mode 100644 index bdd98d2..0000000 --- a/modern_pg/decorators.py +++ /dev/null @@ -1,72 +0,0 @@ -import functools -import logging -import typing - -import asyncpg -import tenacity -from sqlalchemy.exc import DBAPIError - -from modern_pg import settings - - -P = typing.ParamSpec("P") -T = typing.TypeVar("T") -logger = logging.getLogger(__name__) - - -def _connection_retry_handler(exception: BaseException) -> bool: - if ( - isinstance(exception, DBAPIError) - and hasattr(exception, "orig") - and isinstance(exception.orig.__cause__, asyncpg.PostgresConnectionError) # type: ignore[union-attr] - ): - logger.debug("postgres_reconnect, backoff triggered") - return True - - logger.debug("postgres_reconnect, giving up on backoff") - return False - - -def postgres_reconnect(func: typing.Callable[P, typing.Awaitable[T]]) -> typing.Callable[P, typing.Awaitable[T]]: - @tenacity.retry( - stop=tenacity.stop_after_attempt(settings.DB_UTILS_CONNECTION_TRIES), - wait=tenacity.wait_exponential_jitter(), - retry=tenacity.retry_if_exception(_connection_retry_handler), - reraise=True, - before=tenacity.before_log(logger, logging.DEBUG), - ) - @functools.wraps(func) - async def wrapped_method(*args: P.args, **kwargs: P.kwargs) -> T: - return await func(*args, **kwargs) - - return wrapped_method - - -def _transaction_retry_handler(exception: BaseException) -> bool: - if ( - isinstance(exception, DBAPIError) - and hasattr(exception, "orig") - and isinstance(exception.orig.__cause__, asyncpg.SerializationError) # type: ignore[union-attr] - ): - logger.debug("transaction_retry, backoff triggered") - return True - - logger.debug("transaction_retry, giving up on backoff") - return False - - -def transaction_retry( - func: typing.Callable[P, typing.Coroutine[typing.Any, typing.Any, T]], -) -> typing.Callable[P, typing.Coroutine[typing.Any, typing.Any, T]]: - @tenacity.retry( - stop=tenacity.stop_after_attempt(settings.DB_UTILS_TRANSACTIONS_TRIES), - wait=tenacity.wait_exponential_jitter(), - retry=tenacity.retry_if_exception(_transaction_retry_handler), - reraise=True, - before=tenacity.before_log(logger, logging.DEBUG), - ) - @functools.wraps(func) - async def wrapped_method(*args: P.args, **kwargs: P.kwargs) -> T: - return await func(*args, **kwargs) - - return wrapped_method diff --git a/modern_pg/settings.py b/modern_pg/settings.py deleted file mode 100644 index e0ed936..0000000 --- a/modern_pg/settings.py +++ /dev/null @@ -1,6 +0,0 @@ -import os -import typing - - -DB_UTILS_CONNECTION_TRIES: typing.Final = int(os.getenv("DB_UTILS_CONNECTION_TRIES", "3")) -DB_UTILS_TRANSACTIONS_TRIES: typing.Final = int(os.getenv("DB_UTILS_TRANSACTIONS_TRIES", "3")) diff --git a/modern_pg/transaction.py b/modern_pg/transaction.py deleted file mode 100644 index 3209cc6..0000000 --- a/modern_pg/transaction.py +++ /dev/null @@ -1,19 +0,0 @@ -import dataclasses - -import typing_extensions -from sqlalchemy.ext import asyncio as sa_async - - -@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) -class Transaction: - session: sa_async.AsyncSession - - async def __aenter__(self) -> typing_extensions.Self: - await self.session.begin() - return self - - async def __aexit__(self, *_: object) -> None: - await self.session.close() - - async def commit(self) -> None: - await self.session.commit() diff --git a/pyproject.toml b/pyproject.toml index 92e49d3..565e2fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] -name = "modern-pg" -description = "PostgreSQL Tools" +name = "db-try" +description = "PostgreSQL and SQLAlchemy Tools" authors = [ - { name = "Artur Shiriev", email = "me@shiriev.ru" }, + { name = "community-of-python" }, ] readme = "README.md" -requires-python = ">=3.10,<4" +requires-python = ">=3.13,<4" license = "MIT" keywords = [ "python", @@ -13,10 +13,8 @@ keywords = [ "sqlalchemy", ] classifiers = [ - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Typing :: Typed", "Topic :: Software Development :: Libraries", ] @@ -30,11 +28,13 @@ dependencies = [ [project.urls] repository = "https://github.com/modern-python/sa-utils" -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "pytest", "pytest-cov", "pytest-asyncio", +] +lint = [ "asyncpg-stubs", "ruff", "mypy", @@ -45,18 +45,18 @@ requires = ["uv_build"] build-backend = "uv_build" [tool.uv.build-backend] -module-name = "modern_pg" +module-name = "db_try" module-root = "" [tool.mypy] -python_version = "3.10" +python_version = "3.13" strict = true [tool.ruff] fix = false unsafe-fixes = true line-length = 120 -target-version = "py310" +target-version = "py313" [tool.ruff.format] docstring-code-format = true diff --git a/tests/test_connection_factory.py b/tests/test_connection_factory.py index e8f7fcd..5ffc287 100644 --- a/tests/test_connection_factory.py +++ b/tests/test_connection_factory.py @@ -7,7 +7,7 @@ import sqlalchemy from sqlalchemy.ext import asyncio as sa_async -from modern_pg.connections import build_connection_factory +from db_try.connections import build_connection_factory async def test_connection_factory_success() -> None: diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 3a4f241..d20ea90 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -3,17 +3,19 @@ from sqlalchemy.exc import DBAPIError from sqlalchemy.ext import asyncio as sa_async -from modern_pg.decorators import postgres_reconnect, transaction_retry +from db_try.decorators import postgres_retry @pytest.mark.parametrize( "error_code", [ + "08000", # PostgresConnectionError - backoff triggered + "08003", # subclass of PostgresConnectionError - backoff triggered "40001", # SerializationError - backoff triggered "40002", # StatementCompletionUnknownError - backoff not triggered ], ) -async def test_transaction_retry(async_engine: sa_async.AsyncEngine, error_code: str) -> None: +async def test_postgres_retry(async_engine: sa_async.AsyncEngine, error_code: str) -> None: async with async_engine.connect() as connection: await connection.execute( sqlalchemy.text( @@ -28,40 +30,9 @@ async def test_transaction_retry(async_engine: sa_async.AsyncEngine, error_code: ), ) - @transaction_retry + @postgres_retry async def raise_error() -> None: await connection.execute(sqlalchemy.text("SELECT raise_error()")) with pytest.raises(DBAPIError): await raise_error() - - -@pytest.mark.parametrize( - "error_code", - [ - "08000", # PostgresConnectionError - backoff triggered - "08003", # subclass of PostgresConnectionError - backoff triggered - "03000", # backoff not triggered - ], -) -async def test_postgres_reconnect(async_engine: sa_async.AsyncEngine, error_code: str) -> None: - async with async_engine.connect() as connection: - await connection.execute( - sqlalchemy.text( - f""" - CREATE OR REPLACE FUNCTION raise_error() - RETURNS VOID AS $$ - BEGIN - RAISE SQLSTATE '{error_code}'; - END; - $$ LANGUAGE plpgsql; - """, - ), - ) - - @postgres_reconnect - async def other_error() -> None: - await connection.execute(sqlalchemy.text("SELECT raise_error()")) - - with pytest.raises(DBAPIError): - await other_error() diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 09e346b..f79d286 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,6 +1,6 @@ import typing -from modern_pg import helpers, is_dsn_multihost +from db_try import helpers, is_dsn_multihost def test_build_db_dsn() -> None: diff --git a/tests/test_transaction.py b/tests/test_transaction.py index 5b8b2a4..52aa376 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -4,7 +4,7 @@ import pytest from sqlalchemy.ext import asyncio as sa_async -from modern_pg import Transaction +from db_try import Transaction @pytest.fixture @@ -13,6 +13,12 @@ async def transaction(async_engine: sa_async.AsyncEngine) -> typing.AsyncIterato yield Transaction(session=session) +@pytest.fixture +async def transaction_serializable(async_engine: sa_async.AsyncEngine) -> typing.AsyncIterator[Transaction]: + async with sa_async.AsyncSession(async_engine, expire_on_commit=False, autoflush=False) as session: + yield Transaction(session=session, isolation_level="SERIALIZABLE") + + async def test_transaction_with_commit(transaction: Transaction) -> None: async with transaction: assert transaction.session.in_transaction() @@ -20,6 +26,20 @@ async def test_transaction_with_commit(transaction: Transaction) -> None: assert not transaction.session.in_transaction() +async def test_transaction_serializable_with_commit(transaction_serializable: Transaction) -> None: + async with transaction_serializable: + assert transaction_serializable.session.in_transaction() + await transaction_serializable.commit() + assert not transaction_serializable.session.in_transaction() + + +async def test_transaction_with_rollback(transaction: Transaction) -> None: + async with transaction: + assert transaction.session.in_transaction() + await transaction.rollback() + assert not transaction.session.in_transaction() + + async def test_transaction_without_commit(transaction: Transaction) -> None: async with transaction: assert transaction.session.in_transaction()