Skip to content

Commit 52771a9

Browse files
authored
✨ New repository helpers on asyncpg (#6465)
1 parent a0887c2 commit 52771a9

File tree

18 files changed

+378
-70
lines changed

18 files changed

+378
-70
lines changed

packages/postgres-database/src/simcore_postgres_database/models/products.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ class Vendor(TypedDict, total=False):
4141
invitation_url: str # How to request a trial invitation? (if applies)
4242
invitation_form: bool # If True, it takes precendence over invitation_url and asks the FE to show the form (if defined)
4343

44-
has_landing_page: bool # Is Landing page enabled
45-
4644
release_notes_url_template: str # a template url where `{vtag}` will be replaced, eg: "http://example.com/{vtag}.md"
4745

4846

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import logging
2+
from collections.abc import AsyncIterator
3+
from contextlib import asynccontextmanager
4+
5+
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
6+
7+
_logger = logging.getLogger(__name__)
8+
9+
10+
@asynccontextmanager
11+
async def pass_or_acquire_connection(
12+
engine: AsyncEngine, connection: AsyncConnection | None = None
13+
) -> AsyncIterator[AsyncConnection]:
14+
# NOTE: When connection is passed, the engine is actually not needed
15+
# NOTE: Creator is responsible of closing connection
16+
is_connection_created = connection is None
17+
if is_connection_created:
18+
connection = await engine.connect()
19+
try:
20+
assert connection # nosec
21+
yield connection
22+
finally:
23+
assert connection # nosec
24+
assert not connection.closed # nosec
25+
if is_connection_created and connection:
26+
await connection.close()
27+
28+
29+
@asynccontextmanager
30+
async def transaction_context(
31+
engine: AsyncEngine, connection: AsyncConnection | None = None
32+
):
33+
async with pass_or_acquire_connection(engine, connection) as conn:
34+
if conn.in_transaction():
35+
async with conn.begin_nested(): # inner transaction (savepoint)
36+
yield conn
37+
else:
38+
try:
39+
async with conn.begin(): # outer transaction (savepoint)
40+
yield conn
41+
finally:
42+
assert not conn.closed # nosec
43+
assert not conn.in_transaction() # nosec

packages/postgres-database/tests/conftest.py

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# pylint: disable=unused-variable
55

66
import uuid
7+
import warnings
78
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
89
from pathlib import Path
910

@@ -37,6 +38,7 @@
3738
user_to_groups,
3839
users,
3940
)
41+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
4042

4143
pytest_plugins = [
4244
"pytest_simcore.pytest_global_environs",
@@ -81,6 +83,30 @@ def _make(is_async=True) -> Awaitable[Engine] | sa.engine.base.Engine:
8183
return _make
8284

8385

86+
@pytest.fixture
87+
def make_asyncpg_engine(postgres_service: str) -> Callable[[bool], AsyncEngine]:
88+
# NOTE: users is responsible of `await engine.dispose()`
89+
dsn = postgres_service.replace("postgresql://", "postgresql+asyncpg://")
90+
minsize = 1
91+
maxsize = 50
92+
93+
def _(echo: bool):
94+
engine: AsyncEngine = create_async_engine(
95+
dsn,
96+
pool_size=minsize,
97+
max_overflow=maxsize - minsize,
98+
connect_args={
99+
"server_settings": {"application_name": "postgres_database_tests"}
100+
},
101+
pool_pre_ping=True, # https://docs.sqlalchemy.org/en/14/core/pooling.html#dealing-with-disconnects
102+
future=True, # this uses sqlalchemy 2.0 API, shall be removed when sqlalchemy 2.0 is released
103+
echo=echo,
104+
)
105+
return engine
106+
107+
return _
108+
109+
84110
def is_postgres_responsive(dsn) -> bool:
85111
"""Check if something responds to ``url``"""
86112
try:
@@ -107,6 +133,11 @@ def pg_sa_engine(
107133
) -> Iterator[sa.engine.Engine]:
108134
"""
109135
Runs migration to create tables and return a sqlalchemy engine
136+
137+
NOTE: use this fixture to ensure pg db:
138+
- up,
139+
- responsive,
140+
- init (w/ tables) and/or migrated
110141
"""
111142
# NOTE: Using migration to upgrade/downgrade is not
112143
# such a great idea since these tests are used while developing
@@ -142,29 +173,56 @@ def pg_sa_engine(
142173

143174

144175
@pytest.fixture
145-
async def pg_engine(
176+
async def aiopg_engine(
146177
pg_sa_engine: sa.engine.Engine, make_engine: Callable
147178
) -> AsyncIterator[Engine]:
148179
"""
149180
Return an aiopg.sa engine connected to a responsive and migrated pg database
150181
"""
151-
async_engine = await make_engine(is_async=True)
152182

153-
yield async_engine
183+
aiopg_sa_engine = await make_engine(is_async=True)
184+
185+
warnings.warn(
186+
"The 'aiopg_engine' is deprecated since we are replacing `aiopg` library by `sqlalchemy.ext.asyncio`."
187+
"SEE https://github.com/ITISFoundation/osparc-simcore/issues/4529. "
188+
"Please use 'asyncpg_engine' instead.",
189+
DeprecationWarning,
190+
stacklevel=2,
191+
)
192+
193+
yield aiopg_sa_engine
154194

155195
# closes async-engine connections and terminates
156-
async_engine.close()
157-
await async_engine.wait_closed()
158-
async_engine.terminate()
196+
aiopg_sa_engine.close()
197+
await aiopg_sa_engine.wait_closed()
198+
aiopg_sa_engine.terminate()
159199

160200

161201
@pytest.fixture
162-
async def connection(pg_engine: Engine) -> AsyncIterator[SAConnection]:
202+
async def connection(aiopg_engine: Engine) -> AsyncIterator[SAConnection]:
163203
"""Returns an aiopg.sa connection from an engine to a fully furnished and ready pg database"""
164-
async with pg_engine.acquire() as _conn:
204+
async with aiopg_engine.acquire() as _conn:
165205
yield _conn
166206

167207

208+
@pytest.fixture
209+
async def asyncpg_engine(
210+
is_pdb_enabled: bool,
211+
pg_sa_engine: sa.engine.Engine,
212+
make_asyncpg_engine: Callable[[bool], AsyncEngine],
213+
) -> AsyncIterator[AsyncEngine]:
214+
215+
assert (
216+
pg_sa_engine
217+
), "Ensures pg db up, responsive, init (w/ tables) and/or migrated"
218+
219+
_apg_engine = make_asyncpg_engine(is_pdb_enabled)
220+
221+
yield _apg_engine
222+
223+
await _apg_engine.dispose()
224+
225+
168226
#
169227
# FACTORY FIXTURES
170228
#
@@ -240,7 +298,7 @@ async def _creator(conn, group: RowProxy | None = None, **overrides) -> RowProxy
240298

241299
@pytest.fixture
242300
async def create_fake_cluster(
243-
pg_engine: Engine, faker: Faker
301+
aiopg_engine: Engine, faker: Faker
244302
) -> AsyncIterator[Callable[..., Awaitable[int]]]:
245303
cluster_ids = []
246304
assert cluster_to_groups is not None
@@ -254,7 +312,7 @@ async def _creator(**overrides) -> int:
254312
"authentication": faker.pydict(value_types=[str]),
255313
}
256314
insert_values.update(overrides)
257-
async with pg_engine.acquire() as conn:
315+
async with aiopg_engine.acquire() as conn:
258316
cluster_id = await conn.scalar(
259317
clusters.insert().values(**insert_values).returning(clusters.c.id)
260318
)
@@ -265,13 +323,13 @@ async def _creator(**overrides) -> int:
265323
yield _creator
266324

267325
# cleanup
268-
async with pg_engine.acquire() as conn:
326+
async with aiopg_engine.acquire() as conn:
269327
await conn.execute(clusters.delete().where(clusters.c.id.in_(cluster_ids)))
270328

271329

272330
@pytest.fixture
273331
async def create_fake_project(
274-
pg_engine: Engine,
332+
aiopg_engine: Engine,
275333
) -> AsyncIterator[Callable[..., Awaitable[RowProxy]]]:
276334
created_project_uuids = []
277335

@@ -288,7 +346,7 @@ async def _creator(conn, user: RowProxy, **overrides) -> RowProxy:
288346

289347
yield _creator
290348

291-
async with pg_engine.acquire() as conn:
349+
async with aiopg_engine.acquire() as conn:
292350
await conn.execute(
293351
projects.delete().where(projects.c.uuid.in_(created_project_uuids))
294352
)

packages/postgres-database/tests/products/test_models_products.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@
2626

2727

2828
async def test_load_products(
29-
pg_engine: Engine, make_products_table: Callable, products_regex: dict
29+
aiopg_engine: Engine, make_products_table: Callable, products_regex: dict
3030
):
3131
exclude = {
3232
products.c.created,
3333
products.c.modified,
3434
}
3535

36-
async with pg_engine.acquire() as conn:
36+
async with aiopg_engine.acquire() as conn:
3737
await make_products_table(conn)
3838

3939
stmt = sa.select(*[c for c in products.columns if c not in exclude])
@@ -49,14 +49,14 @@ async def test_load_products(
4949

5050

5151
async def test_jinja2_templates_table(
52-
pg_engine: Engine, osparc_simcore_services_dir: Path
52+
aiopg_engine: Engine, osparc_simcore_services_dir: Path
5353
):
5454
templates_common_dir = (
5555
osparc_simcore_services_dir
5656
/ "web/server/src/simcore_service_webserver/templates/common"
5757
)
5858

59-
async with pg_engine.acquire() as conn:
59+
async with aiopg_engine.acquire() as conn:
6060
templates = []
6161
# templates table
6262
for p in templates_common_dir.glob("*.jinja2"):
@@ -135,7 +135,7 @@ async def test_jinja2_templates_table(
135135

136136

137137
async def test_insert_select_product(
138-
pg_engine: Engine,
138+
aiopg_engine: Engine,
139139
):
140140
osparc_product = {
141141
"name": "osparc",
@@ -174,7 +174,7 @@ async def test_insert_select_product(
174174

175175
print(json.dumps(osparc_product))
176176

177-
async with pg_engine.acquire() as conn:
177+
async with aiopg_engine.acquire() as conn:
178178
# writes
179179
stmt = (
180180
pg_insert(products)

packages/postgres-database/tests/products/test_utils_products.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,24 @@
1919
)
2020

2121

22-
async def test_default_product(pg_engine: Engine, make_products_table: Callable):
23-
async with pg_engine.acquire() as conn:
22+
async def test_default_product(aiopg_engine: Engine, make_products_table: Callable):
23+
async with aiopg_engine.acquire() as conn:
2424
await make_products_table(conn)
2525
default_product = await get_default_product_name(conn)
2626
assert default_product == "s4l"
2727

2828

2929
@pytest.mark.parametrize("pg_sa_engine", ["sqlModels"], indirect=True)
30-
async def test_default_product_undefined(pg_engine: Engine):
31-
async with pg_engine.acquire() as conn:
30+
async def test_default_product_undefined(aiopg_engine: Engine):
31+
async with aiopg_engine.acquire() as conn:
3232
with pytest.raises(ValueError):
3333
await get_default_product_name(conn)
3434

3535

3636
async def test_get_or_create_group_product(
37-
pg_engine: Engine, make_products_table: Callable
37+
aiopg_engine: Engine, make_products_table: Callable
3838
):
39-
async with pg_engine.acquire() as conn:
39+
async with aiopg_engine.acquire() as conn:
4040
await make_products_table(conn)
4141

4242
async for product_row in await conn.execute(
@@ -105,13 +105,13 @@ async def test_get_or_create_group_product(
105105
reason="Not relevant. Will review in https://github.com/ITISFoundation/osparc-simcore/issues/3754"
106106
)
107107
async def test_get_or_create_group_product_concurrent(
108-
pg_engine: Engine, make_products_table: Callable
108+
aiopg_engine: Engine, make_products_table: Callable
109109
):
110-
async with pg_engine.acquire() as conn:
110+
async with aiopg_engine.acquire() as conn:
111111
await make_products_table(conn)
112112

113113
async def _auto_create_products_groups():
114-
async with pg_engine.acquire() as conn:
114+
async with aiopg_engine.acquire() as conn:
115115
async for product_row in await conn.execute(
116116
sa.select(products.c.name, products.c.group_id).order_by(
117117
products.c.priority

packages/postgres-database/tests/projects/conftest.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616

1717

1818
@pytest.fixture
19-
async def user(pg_engine: Engine) -> RowProxy:
19+
async def user(aiopg_engine: Engine) -> RowProxy:
2020
_USERNAME = f"{__name__}.me"
2121
# some user
22-
async with pg_engine.acquire() as conn:
22+
async with aiopg_engine.acquire() as conn:
2323
result: ResultProxy | None = await conn.execute(
2424
users.insert().values(**random_user(name=_USERNAME)).returning(users)
2525
)
@@ -32,10 +32,10 @@ async def user(pg_engine: Engine) -> RowProxy:
3232

3333

3434
@pytest.fixture
35-
async def project(pg_engine: Engine, user: RowProxy) -> RowProxy:
35+
async def project(aiopg_engine: Engine, user: RowProxy) -> RowProxy:
3636
_PARENT_PROJECT_NAME = f"{__name__}.parent"
3737
# a user's project
38-
async with pg_engine.acquire() as conn:
38+
async with aiopg_engine.acquire() as conn:
3939
result: ResultProxy | None = await conn.execute(
4040
projects.insert()
4141
.values(**random_project(prj_owner=user.id, name=_PARENT_PROJECT_NAME))
@@ -50,6 +50,6 @@ async def project(pg_engine: Engine, user: RowProxy) -> RowProxy:
5050

5151

5252
@pytest.fixture
53-
async def conn(pg_engine: Engine) -> AsyncIterable[SAConnection]:
54-
async with pg_engine.acquire() as conn:
53+
async def conn(aiopg_engine: Engine) -> AsyncIterable[SAConnection]:
54+
async with aiopg_engine.acquire() as conn:
5555
yield conn

packages/postgres-database/tests/test_classifiers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ def classifiers_bundle(web_client_resource_folder: Path) -> dict:
3838

3939

4040
async def test_operations_on_group_classifiers(
41-
pg_engine: Engine, classifiers_bundle: dict
41+
aiopg_engine: Engine, classifiers_bundle: dict
4242
):
4343
# NOTE: mostly for TDD
44-
async with pg_engine.acquire() as conn:
44+
async with aiopg_engine.acquire() as conn:
4545
# creates a group
4646
stmt = (
4747
groups.insert()

0 commit comments

Comments
 (0)