Skip to content

Commit a1367c0

Browse files
Code consistency (#358)
Several changes to make the code base more consistent in naming, typing, and use of connection vs session. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 7f6ace1 commit a1367c0

57 files changed

Lines changed: 778 additions & 700 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/development/tests.md

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,58 @@ Some guidelines and things to keep in mind when writing tests:
7171
- Try to keep tests small, so that they fail for one particular reason only.
7272
- Mark tests that update the database in anyway with the `mut` marker (`@pytest.mark.mut`).
7373
- If the test is excessively slow (>0.1 sec) and does not connect to PHP, use a `slow` marker. Tests that include PHP always require roundtrips through other services which makes them slow by default. PHP tests can be filtered out with the automatically generated "php_api" marker.
74-
- Four common fixtures you might need when writing tests are:
75-
- `py_api`: an async client for the Python based REST API
76-
- `php_api`: an async client for the PHP based REST API
77-
- `expdb_test`: an AsyncConnection to the "expdb" OpenML database.
78-
- `user_test`: an AsyncConnection to the "openml" OpenML database.
79-
- Above fixtures have considerable per-test overhead. Use them only when you need them.
8074
- When writing assertions the expected value (a constant, or a php response) should be on the right (`assert response == expected`).
8175

76+
### Fixtures
77+
There are a number of fixtures in `conftest.py`, here is a quick rundown of the most notable ones:
78+
79+
- `py_api` and `php_api`: an async client for the Python- and PHP-based REST APIs, respectively. The `py_api` client has its normal dependency injection for database connections patched to use the connection and session fixtures below.
80+
- `expdb_connection` and `userdb_connection`: an AsyncConnection to the "expdb" OpenML database. This fixture is function-scoped and automatically starts a transaction which is rolled back as long as no `commit` is made.
81+
- `expdb_session` and `userdb_session`: an AsyncSession that is bound to the respective connection. This means it also has automatic rollback. Any changes that need to be visible to the `py_api` fixture can be performed on this session (see below).
82+
83+
The pseudocode below shows how you might combine these for a test of the new REST API, either as standalone or when compared to the PHP API:
84+
85+
```python
86+
87+
async def test_python(py_api: httpx.AsyncClient, expdb_session: AsyncSession) -> None:
88+
await expdb_session.execute(text("INSERT INTO dataset ..."), params=...) # Insert dataset with id 42
89+
90+
response = await py_api.get("/datasets/42") # Since this call shares the session, it should retrieve this data
91+
92+
assert ...
93+
# after the test is done, the fixture clean up will ensure the change is not committed to the database, no extra code needed
94+
95+
async def test_python_and_php(py_api: httpx.AsyncClient, php_api: httpx.AsyncClient, expdb_connection: AsyncConnection) -> None:
96+
await expdb_connection.execute(text("INSERT INTO dataset ..."), parameters=...) # Insert dataset with id 42
97+
await expdb_connection.commit() # We need to persist the data in the database, because the PHP REST API cannot see our transaction
98+
99+
response = await php_api.get("/datasets/42") # The PHP REST API can see the dataset, because it exists in the database
100+
response = await py_api.get("/datasets/42") # The Python REST API can see the dataset also
101+
102+
# We need to clean up after ourselves, otherwise the test has side effects.
103+
# This isn't a great pattern, prefer instead the use of context managers which will execute the delete statements even if unexpected exceptions occur.
104+
await expdb_connection.execute(text("DELETE FROM dataset ..."), parameters=...)
105+
await expdb_connection.commit()
106+
107+
```
108+
109+
???- "Why not always use the `*_connection`?"
110+
111+
As this implementation will make more and more use of ORM models, it is more convenient to have access to an `AsyncSession` object which can deal with those models.
112+
Eventually, when verification against PHP REST API output is no longer necessary, we do not even need the `AsyncConnection` objects anymore at all.
113+
114+
115+
Above fixtures have considerable per-test overhead. Use them only when you need them. More details in the next section.
116+
82117
### Writing Tests for an Endpoint
83118
Because the `py_api` and database fixtures provide considerable per-test overhead,
84119
follow these guidelines for writing a test suite for an endpoint.
85120

121+
!!! warning "Code snippets may not work"
122+
123+
The code below is provided as a guide, but isn't automatically tested (yet). This means it may be out of sync.
124+
For examples that work, reference our test suite.
125+
86126
Include tests against `py_api` for input validation specific to that endpoint. Validation in reused components should be tested centrally (e.g., Pagination).
87127
```python
88128
def test_get_dataset_identifier_validation(py_api: httpx.AsyncClient) -> None:
@@ -102,17 +142,17 @@ def test_get_dataset_success(py_api: httpx.AsyncClient) -> None:
102142
For all other tests, do not use `py_api` but call the implementing function directly. For example, do not call `client.get("/datasets/1")` but instead `get_dataset`:
103143

104144
```python
105-
async def test_get_dataset_private_success(expdb_test: AsyncConnection, user_test: AsyncConnection) -> None:
145+
async def test_get_dataset_private_success(expdb_session: AsyncSession, userdb_session: AsyncSession) -> None:
106146
private_dataset = 42
107147
owner_of_that_dataset = OWNER_USER
108-
dataset = await get_dataset(dataset_id=42, user=owner_of_that_dataset, user_db=user_test, expdb_db=expdb_test)
148+
dataset = await get_dataset(dataset_id=42, user=owner_of_that_dataset, userdb_session=userdb_session, expdb_session=expdb_session)
109149
assert dataset.id == private_dataset
110150

111-
async def test_get_dataset_private_access_denied(expdb_test: AsyncConnection, user_test: AsyncConnection) -> None:
151+
async def test_get_dataset_private_access_denied(expdb_session: AsyncSession, userdb_session: AsyncSession) -> None:
112152
private_dataset = 42
113153
owner_of_that_dataset = SOME_USER # Test User defined in a common file
114154
with pytest.raises(DatasetNoAccessError) as e:
115-
await get_dataset(dataset_id=42, user=owner_of_that_dataset, user_db=user_test, expdb_db=expdb_test)
155+
await get_dataset(dataset_id=42, user=owner_of_that_dataset, userdb_session=userdb_session, expdb_session=expdb_session)
116156
assert e.value.status_code == HTTPStatus.FORBIDDEN
117157
```
118158

@@ -164,7 +204,7 @@ def _assert_error_response_equal(py_response, php_response) -> None:
164204
You frequently need to write tests which include fetching from or writing to the database.
165205
There is a test database that is prepopulated with data available for use as defined in our `compose.yaml` file.
166206

167-
The `expdb_test` and `user_test` connections automatically start a transaction during setup and perform a rollback during teardown.
207+
The `expdb_connection`, `userdb_connection`, `expdb_session`, and `userdb_session` fixtures automatically start a transaction during setup and perform a rollback during teardown.
168208
This means that as long as you do not `.commit()` any changes, the data will not persist.
169209
This is a good thing. We do not want our tests to have side effects, as it might lead to inconsistent behavior.
170210

src/core/access.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import TYPE_CHECKING, Any
22

33
from database.users import User
4-
from schemas.datasets import Visibility
4+
from routers.schemas.datasets import Visibility
55

66
if TYPE_CHECKING:
77
from sqlalchemy.engine import Row

src/database/datasets.py

Lines changed: 53 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,99 +15,105 @@
1515
ForeignKeyConstraintError,
1616
)
1717
from database.models.base import UntypedRow
18-
from schemas.datasets import DatasetStatus, Feature
18+
from routers.schemas.datasets import DatasetStatus, Feature
1919

2020
if TYPE_CHECKING:
21-
from sqlalchemy.ext.asyncio import AsyncConnection
21+
from sqlalchemy.ext.asyncio import AsyncSession
2222

2323

24-
async def get(id_: Identifier, connection: AsyncConnection) -> UntypedRow | None:
25-
row = await connection.execute(
24+
async def get(dataset_id: Identifier, session: AsyncSession) -> UntypedRow | None:
25+
row = await session.execute(
2626
text(
2727
"""
2828
SELECT *
2929
FROM dataset
3030
WHERE did = :dataset_id
3131
""",
3232
),
33-
parameters={"dataset_id": id_},
33+
params={"dataset_id": dataset_id},
3434
)
3535
return row.one_or_none()
3636

3737

38-
async def get_file(*, file_id: Identifier, connection: AsyncConnection) -> UntypedRow | None:
39-
row = await connection.execute(
38+
async def get_file(*, file_id: Identifier, session: AsyncSession) -> UntypedRow | None:
39+
row = await session.execute(
4040
text(
4141
"""
4242
SELECT *
4343
FROM file
4444
WHERE id = :file_id
4545
""",
4646
),
47-
parameters={"file_id": file_id},
47+
params={"file_id": file_id},
4848
)
4949
return row.one_or_none()
5050

5151

5252
async def get_tag(
5353
dataset_id: Identifier,
5454
tag: TagString,
55-
connection: AsyncConnection,
55+
session: AsyncSession,
5656
) -> UntypedRow | None:
5757
return (
58-
await connection.execute(
58+
await session.execute(
5959
text(
6060
"""
6161
SELECT *
6262
FROM dataset_tag
6363
WHERE id = :dataset_id AND tag = :tag
6464
""",
6565
),
66-
parameters={"dataset_id": dataset_id, "tag": tag},
66+
params={"dataset_id": dataset_id, "tag": tag},
6767
)
6868
).first()
6969

7070

71-
async def delete_tag(dataset_id: Identifier, tag: TagString, connection: AsyncConnection) -> None:
72-
await connection.execute(
71+
async def delete_tag(dataset_id: Identifier, tag: TagString, session: AsyncSession) -> None:
72+
await session.execute(
7373
text(
7474
"""
7575
DELETE FROM dataset_tag
7676
WHERE id = :dataset_id AND tag = :tag
7777
""",
7878
),
79-
parameters={"dataset_id": dataset_id, "tag": tag},
79+
params={"dataset_id": dataset_id, "tag": tag},
8080
)
8181

8282

83-
async def get_tags_for(id_: Identifier, connection: AsyncConnection) -> list[str]:
84-
row = await connection.execute(
83+
async def get_tags_for(dataset_id: Identifier, session: AsyncSession) -> list[str]:
84+
row = await session.execute(
8585
text(
8686
"""
8787
SELECT *
8888
FROM dataset_tag
8989
WHERE id = :dataset_id
9090
""",
9191
),
92-
parameters={"dataset_id": id_},
92+
params={"dataset_id": dataset_id},
9393
)
9494
rows = row.all()
9595
return [row.tag for row in rows]
9696

9797

98-
async def tag(id_: int, tag_: str, *, user_id: int, connection: AsyncConnection) -> None:
98+
async def tag(
99+
dataset_id: Identifier,
100+
tag: str,
101+
*,
102+
user_id: Identifier,
103+
session: AsyncSession,
104+
) -> None:
99105
try:
100-
await connection.execute(
106+
await session.execute(
101107
text(
102108
"""
103109
INSERT INTO dataset_tag(`id`, `tag`, `uploader`)
104110
VALUES (:dataset_id, :tag, :user_id)
105111
""",
106112
),
107-
parameters={
108-
"dataset_id": id_,
113+
params={
114+
"dataset_id": dataset_id,
109115
"user_id": user_id,
110-
"tag": tag_,
116+
"tag": tag,
111117
},
112118
)
113119
except IntegrityError as e:
@@ -122,11 +128,11 @@ async def tag(id_: int, tag_: str, *, user_id: int, connection: AsyncConnection)
122128

123129

124130
async def get_description(
125-
id_: Identifier,
126-
connection: AsyncConnection,
131+
dataset_id: Identifier,
132+
session: AsyncSession,
127133
) -> UntypedRow | None:
128134
"""Get the most recent description for the dataset."""
129-
row = await connection.execute(
135+
row = await session.execute(
130136
text(
131137
"""
132138
SELECT *
@@ -135,15 +141,15 @@ async def get_description(
135141
ORDER BY version DESC
136142
""",
137143
),
138-
parameters={"dataset_id": id_},
144+
params={"dataset_id": dataset_id},
139145
)
140146
return row.first()
141147

142148

143-
async def get_status(id_: Identifier, connection: AsyncConnection) -> DatasetStatus:
149+
async def get_status(dataset_id: Identifier, session: AsyncSession) -> DatasetStatus:
144150
"""Get most recent status for the dataset."""
145151
row = (
146-
await connection.execute(
152+
await session.execute(
147153
text(
148154
"""
149155
SELECT status
@@ -153,17 +159,17 @@ async def get_status(id_: Identifier, connection: AsyncConnection) -> DatasetSta
153159
LIMIT 1
154160
""",
155161
),
156-
parameters={"dataset_id": id_},
162+
params={"dataset_id": dataset_id},
157163
)
158164
).first()
159165
return DatasetStatus(row.status) if row else DatasetStatus.IN_PREPARATION
160166

161167

162168
async def get_latest_processing_update(
163169
dataset_id: Identifier,
164-
connection: AsyncConnection,
170+
session: AsyncSession,
165171
) -> UntypedRow | None:
166-
row = await connection.execute(
172+
row = await session.execute(
167173
text(
168174
"""
169175
SELECT *
@@ -172,13 +178,13 @@ async def get_latest_processing_update(
172178
ORDER BY processing_date DESC
173179
""",
174180
),
175-
parameters={"dataset_id": dataset_id},
181+
params={"dataset_id": dataset_id},
176182
)
177183
return row.first()
178184

179185

180-
async def get_features(dataset_id: Identifier, connection: AsyncConnection) -> list[Feature]:
181-
row = await connection.execute(
186+
async def get_features(dataset_id: Identifier, session: AsyncSession) -> list[Feature]:
187+
row = await session.execute(
182188
text(
183189
"""
184190
SELECT `index`,`name`,`data_type`,`is_target`,
@@ -187,25 +193,25 @@ async def get_features(dataset_id: Identifier, connection: AsyncConnection) -> l
187193
WHERE `did` = :dataset_id
188194
""",
189195
),
190-
parameters={"dataset_id": dataset_id},
196+
params={"dataset_id": dataset_id},
191197
)
192198
rows = row.mappings().all()
193199
return [Feature(**row, nominal_values=None) for row in rows]
194200

195201

196202
async def get_feature_ontologies(
197203
dataset_id: Identifier,
198-
connection: AsyncConnection,
204+
session: AsyncSession,
199205
) -> dict[int, list[str]]:
200-
rows = await connection.execute(
206+
rows = await session.execute(
201207
text(
202208
"""
203209
SELECT `index`, `value`
204210
FROM data_feature_description
205211
WHERE `did` = :dataset_id AND `description_type` = 'ontology'
206212
""",
207213
),
208-
parameters={"dataset_id": dataset_id},
214+
params={"dataset_id": dataset_id},
209215
)
210216
ontologies: dict[int, list[str]] = defaultdict(list)
211217
for row in rows.mappings():
@@ -217,17 +223,17 @@ async def get_feature_values(
217223
dataset_id: Identifier,
218224
*,
219225
feature_index: int,
220-
connection: AsyncConnection,
226+
session: AsyncSession,
221227
) -> list[str]:
222-
row = await connection.execute(
228+
row = await session.execute(
223229
text(
224230
"""
225231
SELECT `value`
226232
FROM data_feature_value
227233
WHERE `did` = :dataset_id AND `index` = :feature_index
228234
""",
229235
),
230-
parameters={"dataset_id": dataset_id, "feature_index": feature_index},
236+
params={"dataset_id": dataset_id, "feature_index": feature_index},
231237
)
232238
rows = row.all()
233239
return [row.value for row in rows]
@@ -238,16 +244,16 @@ async def update_status(
238244
status: Literal[DatasetStatus.ACTIVE, DatasetStatus.DEACTIVATED],
239245
*,
240246
user_id: Identifier,
241-
connection: AsyncConnection,
247+
session: AsyncSession,
242248
) -> None:
243-
await connection.execute(
249+
await session.execute(
244250
text(
245251
"""
246252
INSERT INTO dataset_status(`did`,`status`,`status_date`,`user_id`)
247253
VALUES (:dataset, :status, :date, :user)
248254
""",
249255
),
250-
parameters={
256+
params={
251257
"dataset": dataset_id,
252258
"status": status,
253259
"date": datetime.datetime.now(datetime.UTC),
@@ -256,13 +262,13 @@ async def update_status(
256262
)
257263

258264

259-
async def remove_deactivated_status(dataset_id: Identifier, connection: AsyncConnection) -> None:
260-
await connection.execute(
265+
async def remove_deactivated_status(dataset_id: Identifier, session: AsyncSession) -> None:
266+
await session.execute(
261267
text(
262268
"""
263269
DELETE FROM dataset_status
264270
WHERE `did` = :data AND `status`='deactivated'
265271
""",
266272
),
267-
parameters={"data": dataset_id},
273+
params={"data": dataset_id},
268274
)

0 commit comments

Comments
 (0)