Compare commits

...
4 Commits
Author SHA1 Message Date
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
22f307d0fc ⬆ Bump starlette from 1.0.1 to 1.3.1 (#315)
Bumps [starlette](https://github.com/Kludex/starlette) from 1.0.1 to 1.3.1.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/1.0.1...1.3.1)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 15:27:11 +02:00
d3vyce 2641881df5 Version 4.1.3 2026-06-09 12:48:01 -04:00
d3vyceandGitHub 49b579bcec feat: add optional prefix parameter to create_worker_database and worker_database_url (#306) 2026-06-09 18:46:09 +02:00
d3vyceandGitHub 4bb4287922 fix: drop worker database with force to handle active connections (#304) 2026-06-09 18:34:44 +02:00
6 changed files with 118 additions and 25 deletions
+5 -1
View File
@@ -89,7 +89,7 @@ async with create_db_session(
## Parallel testing with pytest-xdist
The fixtures above work with `pytest-xdist` out of the box. Each worker gets its own database suffixed with the worker name (e.g. `myapp_gw0`, `myapp_gw1`).
The fixtures above work with `pytest-xdist` out of the box. Each worker gets its own database named after the worker (e.g. `gw0`, `gw1`). Pass `prefix` to namespace the database (e.g. `prefix="myapp"` → `myapp_gw0`).
Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils.worker_database_url) to derive the per-worker URL manually if needed:
@@ -97,6 +97,10 @@ Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils
from fastapi_toolsets.pytest import worker_database_url
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test")
# → "postgresql+asyncpg://user:pass@localhost/gw0" under xdist
# → "postgresql+asyncpg://user:pass@localhost/test" otherwise
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test", prefix="myapp")
# → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist
# → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise
```
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fastapi-toolsets"
version = "4.1.2"
version = "4.1.3"
description = "Production-ready utilities for FastAPI applications"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -21,4 +21,4 @@ Example usage:
return Response(data={"user": user.username}, message="Success")
"""
__version__ = "4.1.2"
__version__ = "4.1.3"
+26 -8
View File
@@ -34,12 +34,18 @@ def _get_xdist_worker(default_test_db: str) -> str:
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
def worker_database_url(database_url: str, default_test_db: str) -> str:
def worker_database_url(
database_url: str,
default_test_db: str,
*,
prefix: str | None = None,
) -> str:
"""Derive a per-worker database URL for pytest-xdist parallel runs.
Appends ``_{worker_name}`` to the database name so each xdist worker
operates on its own database. When not running under xdist,
``_{default_test_db}`` is appended instead.
Sets the database name to the worker name so each xdist worker operates
on its own database. When not running under xdist, *default_test_db* is
used instead. When *prefix* is provided, the name becomes
``{prefix}_{worker}``.
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
variable (set automatically by xdist in each worker process).
@@ -48,6 +54,9 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
database_url: Original database connection URL.
default_test_db: Suffix appended to the database name when
``PYTEST_XDIST_WORKER`` is not set.
prefix: Optional prefix prepended to the worker name
(e.g. ``"test"`` → ``"test_gw0"``). Without it, the database
name is just the worker name (e.g. ``"gw0"``).
Returns:
A database URL with a worker- or default-specific database name.
@@ -55,7 +64,8 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
worker = _get_xdist_worker(default_test_db=default_test_db)
url = make_url(database_url)
url = url.set(database=f"{url.database}_{worker}")
db_name = f"{prefix}_{worker}" if prefix else worker
url = url.set(database=db_name)
return url.render_as_string(hide_password=False)
@@ -64,6 +74,7 @@ async def create_worker_database(
database_url: str,
default_test_db: str = "test_db",
*,
prefix: str | None = None,
server_url: str | None = None,
) -> AsyncGenerator[str, None]:
"""Create and drop a per-worker database for pytest-xdist isolation.
@@ -80,6 +91,9 @@ async def create_worker_database(
the worker database name).
default_test_db: Suffix appended to the database name when
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
prefix: Optional prefix prepended to the worker name
(e.g. ``prefix="test"`` → ``"test_gw0"``). Without it, the
database name is just the worker name (e.g. ``"gw0"``).
server_url: URL used for server-level DDL (must point to an existing
database on the same server). Defaults to *database_url* with the
database omitted, letting asyncpg fall back to the username.
@@ -107,7 +121,7 @@ async def create_worker_database(
```
"""
worker_url = worker_database_url(
database_url=database_url, default_test_db=default_test_db
database_url=database_url, default_test_db=default_test_db, prefix=prefix
)
worker_db_name = make_url(worker_url).database
assert worker_db_name is not None
@@ -125,13 +139,17 @@ async def create_worker_database(
engine = create_async_engine(_server_url, isolation_level="AUTOCOMMIT")
try:
async with engine.connect() as conn:
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
await conn.execute(
text(f"DROP DATABASE IF EXISTS {worker_db_name} WITH (FORCE)")
)
await create_database(db_name=worker_db_name, server_url=_server_url)
yield worker_url
async with engine.connect() as conn:
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
await conn.execute(
text(f"DROP DATABASE IF EXISTS {worker_db_name} WITH (FORCE)")
)
finally:
await engine.dispose()
+81 -10
View File
@@ -442,21 +442,19 @@ class TestGetXdistWorker:
class TestWorkerDatabaseUrl:
"""Tests for worker_database_url helper."""
def test_appends_default_test_db_without_xdist(
self, monkeypatch: pytest.MonkeyPatch
):
"""default_test_db is appended when not running under xdist."""
def test_uses_default_test_db_without_xdist(self, monkeypatch: pytest.MonkeyPatch):
"""default_test_db is used as the database name when not running under xdist."""
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
result = worker_database_url(url, default_test_db="fallback")
assert make_url(result).database == "mydb_fallback"
assert make_url(result).database == "fallback"
def test_appends_worker_id_to_database_name(self, monkeypatch: pytest.MonkeyPatch):
"""Worker name is appended to the database name."""
def test_uses_worker_id_as_database_name(self, monkeypatch: pytest.MonkeyPatch):
"""Worker name is used as the database name."""
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
url = "postgresql+asyncpg://user:pass@localhost:5432/db"
result = worker_database_url(url, default_test_db="unused")
assert make_url(result).database == "db_gw0"
assert make_url(result).database == "gw0"
def test_preserves_url_components(self, monkeypatch: pytest.MonkeyPatch):
"""Host, port, username, password, and driver are preserved."""
@@ -469,7 +467,21 @@ class TestWorkerDatabaseUrl:
assert result.password == "secret"
assert result.host == "dbhost"
assert result.port == 6543
assert result.database == "testdb_gw2"
assert result.database == "gw2"
def test_prefix_with_xdist(self, monkeypatch: pytest.MonkeyPatch):
"""prefix is prepended to the worker name when running under xdist."""
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
result = worker_database_url(url, default_test_db="unused", prefix="myapp")
assert make_url(result).database == "myapp_gw0"
def test_prefix_without_xdist(self, monkeypatch: pytest.MonkeyPatch):
"""prefix is prepended to default_test_db when not running under xdist."""
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
result = worker_database_url(url, default_test_db="test", prefix="myapp")
assert make_url(result).database == "myapp_test"
class TestCreateWorkerDatabase:
@@ -479,7 +491,7 @@ class TestCreateWorkerDatabase:
async def test_creates_default_db_without_xdist(
self, monkeypatch: pytest.MonkeyPatch
):
"""Without xdist, creates a database suffixed with default_test_db."""
"""Without xdist, creates a database named after default_test_db."""
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
default_test_db = "no_xdist_default"
expected_db = make_url(
@@ -626,6 +638,65 @@ class TestCreateWorkerDatabase:
assert result.scalar() == 1
await engine.dispose()
@pytest.mark.anyio
async def test_drops_database_with_active_connections(
self, monkeypatch: pytest.MonkeyPatch
):
"""DROP DATABASE succeeds even when a connection is still open to it."""
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_active_conn")
expected_db = make_url(
worker_database_url(DATABASE_URL, default_test_db="unused")
).database
lingering_engine = None
async with create_worker_database(DATABASE_URL) as url:
# Open a connection to the worker DB and intentionally leave it open.
lingering_engine = create_async_engine(url)
async with lingering_engine.connect():
pass # connection returned to pool but engine not disposed
# If WITH (FORCE) is absent the DROP above would raise; reaching here means it worked.
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT 1 FROM pg_database WHERE datname = :name"),
{"name": expected_db},
)
assert result.scalar() is None
await engine.dispose()
if lingering_engine:
await lingering_engine.dispose()
@pytest.mark.anyio
async def test_prefix_names_database(self, monkeypatch: pytest.MonkeyPatch):
"""prefix is prepended to the worker name in the created database."""
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_prefix")
expected_db = make_url(
worker_database_url(DATABASE_URL, default_test_db="unused", prefix="pfx")
).database
assert expected_db == "pfx_gw_prefix"
async with create_worker_database(DATABASE_URL, prefix="pfx") as url:
assert make_url(url).database == expected_db
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT 1 FROM pg_database WHERE datname = :name"),
{"name": expected_db},
)
assert result.scalar() == 1
await engine.dispose()
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT 1 FROM pg_database WHERE datname = :name"),
{"name": expected_db},
)
assert result.scalar() is None
await engine.dispose()
class _LocalBase(DeclarativeBase):
pass
Generated
+4 -4
View File
@@ -330,7 +330,7 @@ wheels = [
[[package]]
name = "fastapi-toolsets"
version = "4.1.2"
version = "4.1.3"
source = { editable = "." }
dependencies = [
{ name = "asyncpg" },
@@ -1262,15 +1262,15 @@ asyncio = [
[[package]]
name = "starlette"
version = "1.0.1"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]