sqlakit 0.7.4__tar.gz → 0.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlakit-0.7.4 → sqlakit-0.8.1}/PKG-INFO +1 -1
- {sqlakit-0.7.4 → sqlakit-0.8.1}/pyproject.toml +14 -1
- {sqlakit-0.7.4 → sqlakit-0.8.1}/pyproject.toml.orig +19 -1
- sqlakit-0.8.1/sqlakit/pytest_plugin.py +301 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/LICENSE +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/README.md +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/__init__.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_base.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_db.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_model.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_query.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_recording.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_registry.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_routing.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/_sql.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/exceptions.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/orm.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/py.typed +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/sql.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/testing.py +0 -0
- {sqlakit-0.7.4 → sqlakit-0.8.1}/sqlakit/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.1"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -32,6 +32,9 @@ dependencies = ["sqlalchemy>=2.0.22"]
|
|
|
32
32
|
name = "Anton Ruhlov"
|
|
33
33
|
email = "antonruhlov@gmail.com"
|
|
34
34
|
|
|
35
|
+
[project.entry-points.pytest11]
|
|
36
|
+
sqlakit = "sqlakit.pytest_plugin"
|
|
37
|
+
|
|
35
38
|
[project.urls]
|
|
36
39
|
Repository = "https://github.com/antonrh/sqlakit"
|
|
37
40
|
Documentation = "https://github.com/antonrh/sqlakit"
|
|
@@ -61,6 +64,14 @@ filterwarnings = [
|
|
|
61
64
|
"ignore:unclosed database:ResourceWarning",
|
|
62
65
|
]
|
|
63
66
|
addopts = "-p pytester"
|
|
67
|
+
norecursedirs = [
|
|
68
|
+
"examples/testing_plugin",
|
|
69
|
+
"tests/projects",
|
|
70
|
+
".*",
|
|
71
|
+
"build",
|
|
72
|
+
"dist",
|
|
73
|
+
"node_modules",
|
|
74
|
+
]
|
|
64
75
|
|
|
65
76
|
[tool.ruff]
|
|
66
77
|
include = [
|
|
@@ -116,6 +127,8 @@ ignore = [
|
|
|
116
127
|
"ARG001",
|
|
117
128
|
"TC003",
|
|
118
129
|
]
|
|
130
|
+
"examples/testing_plugin/*.py" = ["S101"]
|
|
131
|
+
"tests/projects/**/*.py" = ["INP001"]
|
|
119
132
|
|
|
120
133
|
[tool.ty.src]
|
|
121
134
|
include = [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.1"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -25,6 +25,9 @@ dependencies = [
|
|
|
25
25
|
"sqlalchemy>=2.0.22",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
+
[project.entry-points.pytest11]
|
|
29
|
+
sqlakit = "sqlakit.pytest_plugin"
|
|
30
|
+
|
|
28
31
|
[project.urls]
|
|
29
32
|
Repository = "https://github.com/antonrh/sqlakit"
|
|
30
33
|
Documentation = "https://github.com/antonrh/sqlakit"
|
|
@@ -92,6 +95,15 @@ filterwarnings = [
|
|
|
92
95
|
]
|
|
93
96
|
# `pytester` runs the conftest the docs show, in a session of its own.
|
|
94
97
|
addopts = "-p pytester"
|
|
98
|
+
# The example is a project of its own, run by tests/examples, not collected here.
|
|
99
|
+
norecursedirs = [
|
|
100
|
+
"examples/testing_plugin", # a project of its own, run by tests/examples
|
|
101
|
+
"tests/projects", # the projects the plugin tests copy
|
|
102
|
+
".*",
|
|
103
|
+
"build",
|
|
104
|
+
"dist",
|
|
105
|
+
"node_modules",
|
|
106
|
+
]
|
|
95
107
|
|
|
96
108
|
[tool.ruff]
|
|
97
109
|
include = [
|
|
@@ -148,6 +160,12 @@ ignore = [
|
|
|
148
160
|
"ARG001", # FastAPI hands handlers a request they may not use
|
|
149
161
|
"TC003", # examples keep their imports where a reader expects them
|
|
150
162
|
]
|
|
163
|
+
"examples/testing_plugin/*.py" = [
|
|
164
|
+
"S101", # the example is a test suite, and tests assert
|
|
165
|
+
]
|
|
166
|
+
"tests/projects/**/*.py" = [
|
|
167
|
+
"INP001", # a project the tests copy, not a package of this one
|
|
168
|
+
]
|
|
151
169
|
|
|
152
170
|
[tool.ty.src]
|
|
153
171
|
include = [
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""The `db` marker, and the fixtures behind it.
|
|
2
|
+
|
|
3
|
+
Installed with the library, so a project writes the two lines that say which
|
|
4
|
+
database and which tables, and nothing else:
|
|
5
|
+
|
|
6
|
+
```python title="conftest.py"
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from app.db import db
|
|
10
|
+
from app.models import Model
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="session")
|
|
14
|
+
def sqlakit_db() -> Databases:
|
|
15
|
+
return db
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(scope="session")
|
|
19
|
+
def sqlakit_metadata() -> sa.MetaData:
|
|
20
|
+
return Model.metadata
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
A test marked `db` runs in a transaction that rolls back, on every database.
|
|
24
|
+
`using` narrows that to the ones a test works on:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
@pytest.mark.db(using="warehouse")
|
|
28
|
+
@pytest.mark.db(using=["default", "warehouse"])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every other test connects to nothing, and reaching for a session there raises
|
|
32
|
+
`MissingSessionError` rather than opening one.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import inspect
|
|
39
|
+
import warnings
|
|
40
|
+
from contextlib import AsyncExitStack, ExitStack, contextmanager
|
|
41
|
+
from typing import TYPE_CHECKING, Any
|
|
42
|
+
|
|
43
|
+
import pytest
|
|
44
|
+
|
|
45
|
+
from ._registry import db as importable_db
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from collections.abc import AsyncIterator, Iterator
|
|
49
|
+
|
|
50
|
+
import sqlalchemy as sa
|
|
51
|
+
|
|
52
|
+
MARKER = "db"
|
|
53
|
+
SYNC_FIXTURE = "_sqlakit_transaction"
|
|
54
|
+
ASYNC_FIXTURE = "_sqlakit_async_transaction"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
58
|
+
parser.addini(
|
|
59
|
+
"sqlakit",
|
|
60
|
+
"give the tests marked `db` a database, and the rest none",
|
|
61
|
+
type="bool",
|
|
62
|
+
default=False,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
67
|
+
if config.getini("sqlakit"):
|
|
68
|
+
config.addinivalue_line("markers", f"{MARKER}: the test needs a database")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pytest_collection_modifyitems(
|
|
72
|
+
config: pytest.Config, items: list[pytest.Item]
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Give a database to the marked tests and to nothing else.
|
|
75
|
+
|
|
76
|
+
The fixture is added here rather than with `usefixtures` because an async
|
|
77
|
+
one cannot be fetched from a synchronous fixture, and `pytest` reads
|
|
78
|
+
`fixturenames` at this moment.
|
|
79
|
+
"""
|
|
80
|
+
if not config.getini("sqlakit"):
|
|
81
|
+
return
|
|
82
|
+
for item in items:
|
|
83
|
+
if not isinstance(item, pytest.Function):
|
|
84
|
+
continue
|
|
85
|
+
if item.get_closest_marker(MARKER) is None:
|
|
86
|
+
continue
|
|
87
|
+
awaited = inspect.iscoroutinefunction(item.function)
|
|
88
|
+
name = ASYNC_FIXTURE if awaited else SYNC_FIXTURE
|
|
89
|
+
item.fixturenames.insert(_before_the_test_s_own(item), name)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _before_the_test_s_own(item: pytest.Function) -> int:
|
|
93
|
+
"""Return where the transaction goes among the fixtures of a test.
|
|
94
|
+
|
|
95
|
+
`fixturenames` is the order they set up in. A fixture of the test's own
|
|
96
|
+
that writes has to run inside the transaction, and one of a wider scope,
|
|
97
|
+
the schema or a module that seeds itself, has to run outside it.
|
|
98
|
+
"""
|
|
99
|
+
known = item._fixtureinfo.name2fixturedefs # noqa: SLF001
|
|
100
|
+
for index, name in enumerate(item.fixturenames):
|
|
101
|
+
defined = known.get(name)
|
|
102
|
+
if defined and defined[-1].scope == "function":
|
|
103
|
+
return index
|
|
104
|
+
return len(item.fixturenames)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.fixture(scope="session")
|
|
108
|
+
def sqlakit_db(sqlakit_base: Any) -> Any: # noqa: ANN401
|
|
109
|
+
"""Return the database the marked tests run on.
|
|
110
|
+
|
|
111
|
+
The one the models live on, which is the importable registry unless they
|
|
112
|
+
were given a `Database` of their own. Override it for a project with no
|
|
113
|
+
model layer.
|
|
114
|
+
"""
|
|
115
|
+
if sqlakit_base is None:
|
|
116
|
+
if not importable_db.is_configured:
|
|
117
|
+
pytest.fail(
|
|
118
|
+
"sqlakit has no database to test on. Define `sqlakit_base` with "
|
|
119
|
+
"the base your models inherit, or `sqlakit_db` with the database "
|
|
120
|
+
"itself.",
|
|
121
|
+
pytrace=False,
|
|
122
|
+
)
|
|
123
|
+
return importable_db
|
|
124
|
+
database = getattr(sqlakit_base, "db", None)
|
|
125
|
+
if database is None:
|
|
126
|
+
pytest.fail(
|
|
127
|
+
f"`sqlakit_base` returned `{sqlakit_base.__name__}`, which is not on "
|
|
128
|
+
"the model layer and names no database. Return the base your models "
|
|
129
|
+
"inherit, or define `sqlakit_db` instead.",
|
|
130
|
+
pytrace=False,
|
|
131
|
+
)
|
|
132
|
+
return database
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.fixture(scope="session")
|
|
136
|
+
def sqlakit_base() -> Any | None: # noqa: ANN401
|
|
137
|
+
"""Return the declarative base of the models under test, or None.
|
|
138
|
+
|
|
139
|
+
Every alias gets the tables of the models pointed at it, so a project on
|
|
140
|
+
more than one database says this and nothing else.
|
|
141
|
+
"""
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.fixture(scope="session")
|
|
146
|
+
def sqlakit_metadata() -> sa.MetaData | None:
|
|
147
|
+
"""Return the tables to create for the session, or None to create none.
|
|
148
|
+
|
|
149
|
+
For metadata outside the model layer. None suits a suite running against a
|
|
150
|
+
schema built elsewhere, by migrations or by a fixture of the project's own.
|
|
151
|
+
"""
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.fixture(scope="session")
|
|
156
|
+
def sqlakit_schema(
|
|
157
|
+
sqlakit_db: Any, # noqa: ANN401
|
|
158
|
+
sqlakit_base: Any, # noqa: ANN401
|
|
159
|
+
sqlakit_metadata: sa.MetaData | None,
|
|
160
|
+
) -> Iterator[None]:
|
|
161
|
+
"""Build the schema for the session, and take it down after.
|
|
162
|
+
|
|
163
|
+
Override it to build the schema another way, `Alembic` against a server
|
|
164
|
+
`pytest-docker` started among them:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
@pytest.fixture(scope="session")
|
|
168
|
+
def sqlakit_schema(alembic_config, _postgres):
|
|
169
|
+
alembic.command.upgrade(alembic_config, "head")
|
|
170
|
+
|
|
171
|
+
Yield:
|
|
172
|
+
alembic.command.downgrade(alembic_config, "base")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
"""
|
|
176
|
+
with _entered(_schema_blocks(sqlakit_db, sqlakit_base, sqlakit_metadata)):
|
|
177
|
+
yield
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.fixture(scope="session")
|
|
181
|
+
def sqlakit_seed(sqlakit_schema: None) -> None: # noqa: ARG001 - after the schema
|
|
182
|
+
"""Write the rows every test starts from, committed, once for the session.
|
|
183
|
+
|
|
184
|
+
A test's own writes roll back around it, and these stay:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
@pytest.fixture(scope="session")
|
|
188
|
+
def sqlakit_seed(sqlakit_schema):
|
|
189
|
+
with db.transaction():
|
|
190
|
+
Plan(name="free").save()
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
It runs when the first marked test does, so a suite that needs no database
|
|
194
|
+
writes nothing.
|
|
195
|
+
"""
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.fixture
|
|
200
|
+
def _sqlakit_transaction(
|
|
201
|
+
request: pytest.FixtureRequest,
|
|
202
|
+
sqlakit_db: Any, # noqa: ANN401
|
|
203
|
+
sqlakit_seed: None, # noqa: ARG001 - requested so the rows are there
|
|
204
|
+
) -> Iterator[None]:
|
|
205
|
+
with ExitStack() as stack:
|
|
206
|
+
for block in _rolled_back(sqlakit_db, _asked_for(request)):
|
|
207
|
+
stack.enter_context(block)
|
|
208
|
+
yield
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.fixture
|
|
212
|
+
async def _sqlakit_async_transaction(
|
|
213
|
+
request: pytest.FixtureRequest,
|
|
214
|
+
sqlakit_db: Any, # noqa: ANN401
|
|
215
|
+
sqlakit_seed: None, # noqa: ARG001 - requested so the rows are there
|
|
216
|
+
) -> AsyncIterator[None]:
|
|
217
|
+
async with AsyncExitStack() as stack:
|
|
218
|
+
for block in _rolled_back(sqlakit_db, _asked_for(request)):
|
|
219
|
+
await stack.enter_async_context(block)
|
|
220
|
+
yield
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@contextmanager
|
|
224
|
+
def _entered(blocks: list[Any]) -> Iterator[None]:
|
|
225
|
+
"""Hold these blocks open, awaiting them when that is what they need.
|
|
226
|
+
|
|
227
|
+
An `asyncio` database builds its schema in a coroutine, and this fixture is
|
|
228
|
+
not one: a session of its own runs it, which the tests never touch. They
|
|
229
|
+
open their transactions on the loop `anyio` gives them.
|
|
230
|
+
"""
|
|
231
|
+
if not any(hasattr(block, "__aenter__") for block in blocks):
|
|
232
|
+
with ExitStack() as stack:
|
|
233
|
+
for block in blocks:
|
|
234
|
+
stack.enter_context(block)
|
|
235
|
+
yield
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
stack = AsyncExitStack()
|
|
239
|
+
loop = asyncio.new_event_loop()
|
|
240
|
+
try:
|
|
241
|
+
for block in blocks:
|
|
242
|
+
loop.run_until_complete(stack.enter_async_context(block))
|
|
243
|
+
yield
|
|
244
|
+
finally:
|
|
245
|
+
loop.run_until_complete(stack.aclose())
|
|
246
|
+
loop.close()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _schema_blocks(
|
|
250
|
+
db: Any, # noqa: ANN401
|
|
251
|
+
base: Any, # noqa: ANN401
|
|
252
|
+
metadata: sa.MetaData | None,
|
|
253
|
+
) -> list[Any]:
|
|
254
|
+
"""Return the blocks that create the schema, one per database.
|
|
255
|
+
|
|
256
|
+
The model layer knows which tables belong on which alias, so a registry
|
|
257
|
+
gets one block per alias. Without it there is one metadata, and one
|
|
258
|
+
database to put it on.
|
|
259
|
+
"""
|
|
260
|
+
if base is not None:
|
|
261
|
+
aliases = getattr(db, "aliases", None) or (None,)
|
|
262
|
+
return [base.provisioned_tables(alias) for alias in aliases]
|
|
263
|
+
if metadata is not None:
|
|
264
|
+
return [db.provisioned_tables(metadata)]
|
|
265
|
+
warnings.warn(
|
|
266
|
+
"sqlakit creates no tables: neither `sqlakit_base` nor "
|
|
267
|
+
"`sqlakit_metadata` is defined. Define one, or replace "
|
|
268
|
+
"`sqlakit_schema` with the fixture that builds your schema.",
|
|
269
|
+
stacklevel=2,
|
|
270
|
+
)
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _asked_for(request: pytest.FixtureRequest) -> tuple[Any, ...]:
|
|
275
|
+
"""Return the databases the marker asks for, by name or in person."""
|
|
276
|
+
marker = request.node.get_closest_marker(MARKER)
|
|
277
|
+
using = None if marker is None else marker.kwargs.get("using")
|
|
278
|
+
if using is None:
|
|
279
|
+
return ()
|
|
280
|
+
if isinstance(using, str) or not isinstance(using, (list, tuple, set, frozenset)):
|
|
281
|
+
return (using,)
|
|
282
|
+
return tuple(using)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _rolled_back(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
|
|
286
|
+
"""Return the blocks that undo what a test writes.
|
|
287
|
+
|
|
288
|
+
A marker with no ``using`` opens every database, which for most projects is
|
|
289
|
+
the one they have. Naming one is how a project on several stops paying for
|
|
290
|
+
a connection to each in the tests that read one.
|
|
291
|
+
"""
|
|
292
|
+
if not using:
|
|
293
|
+
return [
|
|
294
|
+
db.transactions(rollback=True)
|
|
295
|
+
if len(getattr(db, "aliases", ()) or ()) > 1
|
|
296
|
+
else db.transaction(rollback=True)
|
|
297
|
+
]
|
|
298
|
+
return [
|
|
299
|
+
(db[one] if isinstance(one, str) else one).transaction(rollback=True)
|
|
300
|
+
for one in using
|
|
301
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|