sqlakit 0.7.3__tar.gz → 0.8.0__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.
Files changed (27) hide show
  1. {sqlakit-0.7.3 → sqlakit-0.8.0}/PKG-INFO +1 -1
  2. {sqlakit-0.7.3 → sqlakit-0.8.0}/pyproject.toml +14 -1
  3. {sqlakit-0.7.3 → sqlakit-0.8.0}/pyproject.toml.orig +19 -1
  4. sqlakit-0.8.0/sqlakit/pytest_plugin.py +285 -0
  5. {sqlakit-0.7.3 → sqlakit-0.8.0}/LICENSE +0 -0
  6. {sqlakit-0.7.3 → sqlakit-0.8.0}/README.md +0 -0
  7. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/__init__.py +0 -0
  8. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_base.py +0 -0
  9. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_db.py +0 -0
  10. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_discovery.py +0 -0
  11. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_model.py +0 -0
  12. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_query.py +0 -0
  13. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_recording.py +0 -0
  14. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_registry.py +0 -0
  15. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_routing.py +0 -0
  16. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/_sql.py +0 -0
  17. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/asyncio/__init__.py +0 -0
  18. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/asyncio/_db.py +0 -0
  19. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/asyncio/_registry.py +0 -0
  20. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/asyncio/orm.py +0 -0
  21. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/asyncio/sql.py +0 -0
  22. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/exceptions.py +0 -0
  23. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/orm.py +0 -0
  24. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/py.typed +0 -0
  25. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/sql.py +0 -0
  26. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/testing.py +0 -0
  27. {sqlakit-0.7.3 → sqlakit-0.8.0}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.7.3
3
+ Version: 0.8.0
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.7.3"
3
+ version = "0.8.0"
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.7.3"
3
+ version = "0.8.0"
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,285 @@
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
+ item.fixturenames.append(ASYNC_FIXTURE if awaited else SYNC_FIXTURE)
89
+
90
+
91
+ @pytest.fixture(scope="session")
92
+ def sqlakit_db(sqlakit_base: Any) -> Any: # noqa: ANN401
93
+ """Return the database the marked tests run on.
94
+
95
+ The one the models live on, which is the importable registry unless they
96
+ were given a `Database` of their own. Override it for a project with no
97
+ model layer.
98
+ """
99
+ if sqlakit_base is None:
100
+ if not importable_db.is_configured:
101
+ pytest.fail(
102
+ "sqlakit has no database to test on. Define `sqlakit_base` with "
103
+ "the base your models inherit, or `sqlakit_db` with the database "
104
+ "itself.",
105
+ pytrace=False,
106
+ )
107
+ return importable_db
108
+ database = getattr(sqlakit_base, "db", None)
109
+ if database is None:
110
+ pytest.fail(
111
+ f"`sqlakit_base` returned `{sqlakit_base.__name__}`, which is not on "
112
+ "the model layer and names no database. Return the base your models "
113
+ "inherit, or define `sqlakit_db` instead.",
114
+ pytrace=False,
115
+ )
116
+ return database
117
+
118
+
119
+ @pytest.fixture(scope="session")
120
+ def sqlakit_base() -> Any | None: # noqa: ANN401
121
+ """Return the declarative base of the models under test, or None.
122
+
123
+ Every alias gets the tables of the models pointed at it, so a project on
124
+ more than one database says this and nothing else.
125
+ """
126
+ return None
127
+
128
+
129
+ @pytest.fixture(scope="session")
130
+ def sqlakit_metadata() -> sa.MetaData | None:
131
+ """Return the tables to create for the session, or None to create none.
132
+
133
+ For metadata outside the model layer. None suits a suite running against a
134
+ schema built elsewhere, by migrations or by a fixture of the project's own.
135
+ """
136
+ return None
137
+
138
+
139
+ @pytest.fixture(scope="session")
140
+ def sqlakit_schema(
141
+ sqlakit_db: Any, # noqa: ANN401
142
+ sqlakit_base: Any, # noqa: ANN401
143
+ sqlakit_metadata: sa.MetaData | None,
144
+ ) -> Iterator[None]:
145
+ """Build the schema for the session, and take it down after.
146
+
147
+ Override it to build the schema another way, `Alembic` against a server
148
+ `pytest-docker` started among them:
149
+
150
+ ```python
151
+ @pytest.fixture(scope="session")
152
+ def sqlakit_schema(alembic_config, _postgres):
153
+ alembic.command.upgrade(alembic_config, "head")
154
+
155
+ Yield:
156
+ alembic.command.downgrade(alembic_config, "base")
157
+ ```
158
+
159
+ """
160
+ with _entered(_schema_blocks(sqlakit_db, sqlakit_base, sqlakit_metadata)):
161
+ yield
162
+
163
+
164
+ @pytest.fixture(scope="session")
165
+ def sqlakit_seed(sqlakit_schema: None) -> None: # noqa: ARG001 - after the schema
166
+ """Write the rows every test starts from, committed, once for the session.
167
+
168
+ A test's own writes roll back around it, and these stay:
169
+
170
+ ```python
171
+ @pytest.fixture(scope="session")
172
+ def sqlakit_seed(sqlakit_schema):
173
+ with db.transaction():
174
+ Plan(name="free").save()
175
+ ```
176
+
177
+ It runs when the first marked test does, so a suite that needs no database
178
+ writes nothing.
179
+ """
180
+ return
181
+
182
+
183
+ @pytest.fixture
184
+ def _sqlakit_transaction(
185
+ request: pytest.FixtureRequest,
186
+ sqlakit_db: Any, # noqa: ANN401
187
+ sqlakit_seed: None, # noqa: ARG001 - requested so the rows are there
188
+ ) -> Iterator[None]:
189
+ with ExitStack() as stack:
190
+ for block in _rolled_back(sqlakit_db, _asked_for(request)):
191
+ stack.enter_context(block)
192
+ yield
193
+
194
+
195
+ @pytest.fixture
196
+ async def _sqlakit_async_transaction(
197
+ request: pytest.FixtureRequest,
198
+ sqlakit_db: Any, # noqa: ANN401
199
+ sqlakit_seed: None, # noqa: ARG001 - requested so the rows are there
200
+ ) -> AsyncIterator[None]:
201
+ async with AsyncExitStack() as stack:
202
+ for block in _rolled_back(sqlakit_db, _asked_for(request)):
203
+ await stack.enter_async_context(block)
204
+ yield
205
+
206
+
207
+ @contextmanager
208
+ def _entered(blocks: list[Any]) -> Iterator[None]:
209
+ """Hold these blocks open, awaiting them when that is what they need.
210
+
211
+ An `asyncio` database builds its schema in a coroutine, and this fixture is
212
+ not one: a session of its own runs it, which the tests never touch. They
213
+ open their transactions on the loop `anyio` gives them.
214
+ """
215
+ if not any(hasattr(block, "__aenter__") for block in blocks):
216
+ with ExitStack() as stack:
217
+ for block in blocks:
218
+ stack.enter_context(block)
219
+ yield
220
+ return
221
+
222
+ stack = AsyncExitStack()
223
+ loop = asyncio.new_event_loop()
224
+ try:
225
+ for block in blocks:
226
+ loop.run_until_complete(stack.enter_async_context(block))
227
+ yield
228
+ finally:
229
+ loop.run_until_complete(stack.aclose())
230
+ loop.close()
231
+
232
+
233
+ def _schema_blocks(
234
+ db: Any, # noqa: ANN401
235
+ base: Any, # noqa: ANN401
236
+ metadata: sa.MetaData | None,
237
+ ) -> list[Any]:
238
+ """Return the blocks that create the schema, one per database.
239
+
240
+ The model layer knows which tables belong on which alias, so a registry
241
+ gets one block per alias. Without it there is one metadata, and one
242
+ database to put it on.
243
+ """
244
+ if base is not None:
245
+ aliases = getattr(db, "aliases", None) or (None,)
246
+ return [base.provisioned_tables(alias) for alias in aliases]
247
+ if metadata is not None:
248
+ return [db.provisioned_tables(metadata)]
249
+ warnings.warn(
250
+ "sqlakit creates no tables: neither `sqlakit_base` nor "
251
+ "`sqlakit_metadata` is defined. Define one, or replace "
252
+ "`sqlakit_schema` with the fixture that builds your schema.",
253
+ stacklevel=2,
254
+ )
255
+ return []
256
+
257
+
258
+ def _asked_for(request: pytest.FixtureRequest) -> tuple[Any, ...]:
259
+ """Return the databases the marker asks for, by name or in person."""
260
+ marker = request.node.get_closest_marker(MARKER)
261
+ using = None if marker is None else marker.kwargs.get("using")
262
+ if using is None:
263
+ return ()
264
+ if isinstance(using, str) or not isinstance(using, (list, tuple, set, frozenset)):
265
+ return (using,)
266
+ return tuple(using)
267
+
268
+
269
+ def _rolled_back(db: Any, using: tuple[Any, ...]) -> list[Any]: # noqa: ANN401
270
+ """Return the blocks that undo what a test writes.
271
+
272
+ A marker with no ``using`` opens every database, which for most projects is
273
+ the one they have. Naming one is how a project on several stops paying for
274
+ a connection to each in the tests that read one.
275
+ """
276
+ if not using:
277
+ return [
278
+ db.transactions(rollback=True)
279
+ if len(getattr(db, "aliases", ()) or ()) > 1
280
+ else db.transaction(rollback=True)
281
+ ]
282
+ return [
283
+ (db[one] if isinstance(one, str) else one).transaction(rollback=True)
284
+ for one in using
285
+ ]
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