Skip to content

Commit f48b2d3

Browse files
authored
♻️ Maintenance: enhances pytest_simcore tooling (#7274)
1 parent 387826f commit f48b2d3

File tree

17 files changed

+307
-154
lines changed

17 files changed

+307
-154
lines changed

packages/models-library/tests/test_service_settings_labels.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import json
77
from copy import deepcopy
8-
from pprint import pformat
98
from typing import Any, Final, NamedTuple
109

1110
import pytest
@@ -33,6 +32,10 @@
3332
from models_library.services_resources import DEFAULT_SINGLE_SERVICE_NAME
3433
from models_library.utils.string_substitution import TextTemplate
3534
from pydantic import BaseModel, TypeAdapter, ValidationError
35+
from pytest_simcore.pydantic_models import (
36+
assert_validation_model,
37+
iter_model_examples_in_class,
38+
)
3639

3740

3841
class _Parametrization(NamedTuple):
@@ -89,17 +92,23 @@ def test_service_settings():
8992
service_setting.set_destination_containers(["random_value1", "random_value2"])
9093

9194

92-
@pytest.mark.parametrize("model_cls", [SimcoreServiceLabels])
95+
@pytest.mark.parametrize(
96+
"model_cls, example_name, example_data",
97+
iter_model_examples_in_class(SimcoreServiceLabels),
98+
)
9399
def test_correctly_detect_dynamic_sidecar_boot(
94-
model_cls: type[BaseModel], model_cls_examples: dict[str, dict[str, Any]]
100+
model_cls: type[BaseModel], example_name: str, example_data: Any
95101
):
96-
for name, example in model_cls_examples.items():
97-
print(name, ":", pformat(example))
98-
model_instance = TypeAdapter(model_cls).validate_python(example)
99-
assert model_instance.callbacks_mapping is not None
100-
assert model_instance.needs_dynamic_sidecar == (
101-
"simcore.service.paths-mapping" in example
102-
)
102+
103+
model_instance = assert_validation_model(
104+
model_cls, example_name=example_name, example_data=example_data
105+
)
106+
107+
assert isinstance(model_instance, SimcoreServiceLabels)
108+
assert model_instance.callbacks_mapping is not None
109+
assert model_instance.needs_dynamic_sidecar == (
110+
"simcore.service.paths-mapping" in example_data
111+
)
103112

104113

105114
def test_raises_error_if_http_entrypoint_is_missing():

packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ def fake_task(**overrides) -> dict[str, Any]:
220220

221221

222222
def random_product(
223+
*,
223224
group_id: int | None = None,
224225
registration_email_template: str | None = None,
225226
fake: Faker = DEFAULT_FAKER,
@@ -288,6 +289,29 @@ def random_product(
288289
return data
289290

290291

292+
def random_product_price(
293+
*, product_name: str, fake: Faker = DEFAULT_FAKER, **overrides
294+
) -> dict[str, Any]:
295+
from simcore_postgres_database.models.products_prices import products_prices
296+
297+
data = {
298+
"product_name": product_name,
299+
"usd_per_credit": fake.pydecimal(left_digits=2, right_digits=2, positive=True),
300+
"min_payment_amount_usd": fake.pydecimal(
301+
left_digits=2, right_digits=2, positive=True
302+
),
303+
"comment": fake.sentence(),
304+
"valid_from": fake.date_time_this_decade(),
305+
"stripe_price_id": fake.uuid4(),
306+
"stripe_tax_rate_id": fake.uuid4(),
307+
}
308+
309+
assert set(data.keys()).issubset({c.name for c in products_prices.columns})
310+
311+
data.update(overrides)
312+
return data
313+
314+
291315
def utcnow() -> datetime:
292316
return datetime.now(tz=UTC)
293317

packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sqlalchemy as sa
77
from psycopg2 import OperationalError
88
from simcore_postgres_database.models.base import metadata
9-
from sqlalchemy.ext.asyncio import AsyncEngine
9+
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
1010

1111

1212
class PostgresTestConfig(TypedDict):
@@ -76,25 +76,46 @@ def migrated_pg_tables_context(
7676
def is_postgres_responsive(url) -> bool:
7777
"""Check if something responds to ``url``"""
7878
try:
79-
engine = sa.create_engine(url)
80-
conn = engine.connect()
79+
sync_engine = sa.create_engine(url)
80+
conn = sync_engine.connect()
8181
conn.close()
8282
except OperationalError:
8383
return False
8484
return True
8585

8686

87-
async def _insert_and_get_row(
88-
conn, table: sa.Table, values: dict[str, Any], pk_col: sa.Column, pk_value: Any
87+
async def _async_insert_and_get_row(
88+
conn: AsyncConnection,
89+
table: sa.Table,
90+
values: dict[str, Any],
91+
pk_col: sa.Column,
92+
pk_value: Any,
8993
):
9094
result = await conn.execute(table.insert().values(**values).returning(pk_col))
91-
row = result.first()
95+
row = result.one()
9296

9397
# NOTE: DO NO USE row[pk_col] since you will get a deprecation error (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
9498
assert getattr(row, pk_col.name) == pk_value
9599

96100
result = await conn.execute(sa.select(table).where(pk_col == pk_value))
97-
return result.first()
101+
return result.one()
102+
103+
104+
def _sync_insert_and_get_row(
105+
conn: sa.engine.Connection,
106+
table: sa.Table,
107+
values: dict[str, Any],
108+
pk_col: sa.Column,
109+
pk_value: Any,
110+
):
111+
result = conn.execute(table.insert().values(**values).returning(pk_col))
112+
row = result.one()
113+
114+
# NOTE: DO NO USE row[pk_col] since you will get a deprecation error (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
115+
assert getattr(row, pk_col.name) == pk_value
116+
117+
result = conn.execute(sa.select(table).where(pk_col == pk_value))
118+
return result.one()
98119

99120

100121
@asynccontextmanager
@@ -108,14 +129,48 @@ async def insert_and_get_row_lifespan(
108129
) -> AsyncIterator[dict[str, Any]]:
109130
# insert & get
110131
async with sqlalchemy_async_engine.begin() as conn:
111-
row = await _insert_and_get_row(
132+
row = await _async_insert_and_get_row(
112133
conn, table=table, values=values, pk_col=pk_col, pk_value=pk_value
113134
)
114135

136+
assert row
137+
115138
# NOTE: DO NO USE dict(row) since you will get a deprecation error (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
116139
# pylint: disable=protected-access
117140
yield row._asdict()
118141

119142
# delete row
120143
async with sqlalchemy_async_engine.begin() as conn:
121144
await conn.execute(table.delete().where(pk_col == pk_value))
145+
146+
147+
@contextmanager
148+
def sync_insert_and_get_row_lifespan(
149+
sqlalchemy_sync_engine: sa.engine.Engine,
150+
*,
151+
table: sa.Table,
152+
values: dict[str, Any],
153+
pk_col: sa.Column,
154+
pk_value: Any,
155+
) -> Iterator[dict[str, Any]]:
156+
"""sync version of insert_and_get_row_lifespan.
157+
158+
TIP: more convenient for **module-scope fixtures** that setup the
159+
database tables before the app starts since it does not require an `event_loop`
160+
fixture (which is funcition-scoped )
161+
"""
162+
# insert & get
163+
with sqlalchemy_sync_engine.begin() as conn:
164+
row = _sync_insert_and_get_row(
165+
conn, table=table, values=values, pk_col=pk_col, pk_value=pk_value
166+
)
167+
168+
assert row
169+
170+
# NOTE: DO NO USE dict(row) since you will get a deprecation error (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
171+
# pylint: disable=protected-access
172+
yield row._asdict()
173+
174+
# delete row
175+
with sqlalchemy_sync_engine.begin() as conn:
176+
conn.execute(table.delete().where(pk_col == pk_value))

packages/pytest-simcore/src/pytest_simcore/pydantic_models.py

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
import importlib
33
import inspect
44
import itertools
5-
import json
65
import pkgutil
6+
import warnings
77
from collections.abc import Iterator
88
from contextlib import suppress
99
from types import ModuleType
10-
from typing import Any, NamedTuple
10+
from typing import Any, NamedTuple, TypeVar
1111

1212
import pytest
13+
from common_library.json_serialization import json_dumps
1314
from pydantic import BaseModel, ValidationError
1415

1516

@@ -63,18 +64,20 @@ def walk_model_examples_in_package(package: ModuleType) -> Iterator[ModelExample
6364
def iter_model_examples_in_module(module: object) -> Iterator[ModelExample]:
6465
"""Iterates on all examples defined as BaseModelClass.model_config["json_schema_extra"]["example"]
6566
66-
6767
Usage:
68+
import some_package.some_module
6869
6970
@pytest.mark.parametrize(
7071
"model_cls, example_name, example_data",
71-
iter_model_examples_in_module(simcore_service_webserver.storage_schemas),
72+
iter_model_examples_in_module(some_package.some_module),
7273
)
7374
def test_model_examples(
74-
model_cls: type[BaseModel], example_name: int, example_data: Any
75+
model_cls: type[BaseModel], example_name: str, example_data: Any
7576
):
76-
print(example_name, ":", json.dumps(example_data))
77-
assert model_cls.model_validate(example_data)
77+
assert_validation_model(
78+
model_cls, example_name=example_name, example_data=example_data
79+
)
80+
7881
"""
7982

8083
def _is_model_cls(obj) -> bool:
@@ -95,36 +98,69 @@ def _is_model_cls(obj) -> bool:
9598

9699
for model_name, model_cls in inspect.getmembers(module, _is_model_cls):
97100

98-
schema = model_cls.model_json_schema()
101+
yield from iter_model_examples_in_class(model_cls, model_name)
102+
103+
104+
def iter_model_examples_in_class(
105+
model_cls: type[BaseModel], model_name: str | None = None
106+
) -> Iterator[ModelExample]:
107+
"""Iterates on all examples within a base model class
108+
109+
Usage:
110+
111+
@pytest.mark.parametrize(
112+
"model_cls, example_name, example_data",
113+
iter_model_examples_in_class(SomeModelClass),
114+
)
115+
def test_model_examples(
116+
model_cls: type[BaseModel], example_name: str, example_data: Any
117+
):
118+
assert_validation_model(
119+
model_cls, example_name=example_name, example_data=example_data
120+
)
121+
122+
"""
123+
assert issubclass(model_cls, BaseModel) # nosec
99124

100-
if example := schema.get("example"):
125+
if model_name is None:
126+
model_name = f"{model_cls.__module__}.{model_cls.__name__}"
127+
128+
schema = model_cls.model_json_schema()
129+
130+
if example := schema.get("example"):
131+
yield ModelExample(
132+
model_cls=model_cls,
133+
example_name=f"{model_name}_example",
134+
example_data=example,
135+
)
136+
137+
if many_examples := schema.get("examples"):
138+
for index, example in enumerate(many_examples):
101139
yield ModelExample(
102140
model_cls=model_cls,
103-
example_name=f"{model_name}_example",
141+
example_name=f"{model_name}_examples_{index}",
104142
example_data=example,
105143
)
106144

107-
if many_examples := schema.get("examples"):
108-
for index, example in enumerate(many_examples):
109-
yield ModelExample(
110-
model_cls=model_cls,
111-
example_name=f"{model_name}_examples_{index}",
112-
example_data=example,
113-
)
145+
146+
TBaseModel = TypeVar("TBaseModel", bound=BaseModel)
114147

115148

116149
def assert_validation_model(
117-
model_cls: type[BaseModel], example_name: int, example_data: Any
118-
):
150+
model_cls: type[TBaseModel], example_name: str, example_data: Any
151+
) -> TBaseModel:
119152
try:
120-
assert model_cls.model_validate(example_data) is not None
153+
model_instance = model_cls.model_validate(example_data)
121154
except ValidationError as err:
122155
pytest.fail(
123156
f"{example_name} is invalid {model_cls.__module__}.{model_cls.__name__}:"
124-
f"\n{json.dumps(example_data, indent=1)}"
157+
f"\n{json_dumps(example_data, indent=1)}"
125158
f"\nError: {err}"
126159
)
127160

161+
assert isinstance(model_instance, model_cls)
162+
return model_instance
163+
128164

129165
## PYDANTIC MODELS & SCHEMAS -----------------------------------------------------
130166

@@ -134,7 +170,12 @@ def model_cls_examples(model_cls: type[BaseModel]) -> dict[str, dict[str, Any]]:
134170
"""
135171
Extracts examples from pydantic model class Config
136172
"""
137-
173+
warnings.warn(
174+
"The 'model_cls_examples' fixture is deprecated and will be removed in a future version. "
175+
"Please use 'iter_model_example_in_class' or 'iter_model_examples_in_module' as an alternative.",
176+
DeprecationWarning,
177+
stacklevel=2,
178+
)
138179
# Use by defining model_cls as test parametrization
139180
assert model_cls, (
140181
f"Testing against a {model_cls} model that has NO examples. Add them in Config class. "

packages/service-integration/tests/test_versioning.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
# pylint: disable=unused-argument
33
# pylint: disable=unused-variable
44

5-
import json
5+
6+
import itertools
7+
from typing import Any
68

79
import pytest
810
from packaging.version import Version
11+
from pydantic import BaseModel
12+
from pytest_simcore.pydantic_models import (
13+
assert_validation_model,
14+
iter_model_examples_in_class,
15+
)
916
from service_integration.versioning import (
1017
ExecutableVersionInfo,
1118
ServiceVersionInfo,
@@ -45,11 +52,15 @@ def test_bump_version_string(
4552

4653

4754
@pytest.mark.parametrize(
48-
"model_cls",
49-
[ExecutableVersionInfo, ServiceVersionInfo],
55+
"model_cls, example_name, example_data",
56+
itertools.chain(
57+
iter_model_examples_in_class(ExecutableVersionInfo),
58+
iter_model_examples_in_class(ServiceVersionInfo),
59+
),
5060
)
51-
def test_version_info_model_examples(model_cls, model_cls_examples):
52-
for name, example in model_cls_examples.items():
53-
print(name, ":", json.dumps(example, indent=1))
54-
model_instance = model_cls(**example)
55-
assert model_instance, f"Failed with {name}"
61+
def test_version_info_model_examples(
62+
model_cls: type[BaseModel], example_name: str, example_data: Any
63+
):
64+
assert_validation_model(
65+
model_cls, example_name=example_name, example_data=example_data
66+
)

0 commit comments

Comments
 (0)