Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions db_try/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
2 changes: 1 addition & 1 deletion modern_pg/connections.py → db_try/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions db_try/decorators.py
Original file line number Diff line number Diff line change
@@ -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
File renamed without changes.
File renamed without changes.
5 changes: 5 additions & 0 deletions db_try/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import os
import typing


DB_UTILS_RETRIES_NUMBER: typing.Final = int(os.getenv("DB_UTILS_RETRIES_NUMBER", "3"))
28 changes: 28 additions & 0 deletions db_try/transaction.py
Original file line number Diff line number Diff line change
@@ -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()
14 changes: 0 additions & 14 deletions modern_pg/__init__.py

This file was deleted.

72 changes: 0 additions & 72 deletions modern_pg/decorators.py

This file was deleted.

6 changes: 0 additions & 6 deletions modern_pg/settings.py

This file was deleted.

19 changes: 0 additions & 19 deletions modern_pg/transaction.py

This file was deleted.

24 changes: 12 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
[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",
"postgresql",
"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",
]
Expand All @@ -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",
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_connection_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 5 additions & 34 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
2 changes: 1 addition & 1 deletion tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Loading
Loading