simple-module-test 0.0.36__tar.gz → 0.0.38__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.
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/.gitignore +2 -2
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/PKG-INFO +5 -5
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/pyproject.toml +5 -5
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/__init__.py +3 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/_schema.py +23 -13
- simple_module_test-0.0.38/simple_module_test/database.py +199 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/fake_events.py +1 -1
- simple_module_test-0.0.38/simple_module_test/mail.py +49 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/plugin.py +78 -0
- simple_module_test-0.0.38/simple_module_test/waiting.py +83 -0
- simple_module_test-0.0.38/tests/test_schema_reuse_pg.py +75 -0
- simple_module_test-0.0.38/tests/test_waiting_helpers.py +88 -0
- simple_module_test-0.0.38/tests/test_worker_database.py +51 -0
- simple_module_test-0.0.36/simple_module_test/database.py +0 -78
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/LICENSE +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/README.md +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/app_factory.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/fixtures.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/redis_server.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/routes.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/session_cookie.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/setup_wizard.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/tenant_client.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/tests/test_app_factory.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/tests/test_fake_events.py +0 -0
- {simple_module_test-0.0.36 → simple_module_test-0.0.38}/tests/test_plugin_fixtures.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_test
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.38
|
|
4
4
|
Summary: Shared pytest fixtures (app, client, db_session, authenticated_client) for writing simple_module module tests
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -21,13 +21,13 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Testing
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
|
-
Requires-Dist: fastapi
|
|
24
|
+
Requires-Dist: fastapi<0.157,>=0.140
|
|
25
25
|
Requires-Dist: httpx>=0.27
|
|
26
26
|
Requires-Dist: pytest-asyncio>=0.24
|
|
27
27
|
Requires-Dist: pytest>=8.0
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.38
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.38
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.38
|
|
31
31
|
Requires-Dist: sqlalchemy>=2.0
|
|
32
32
|
Description-Content-Type: text/markdown
|
|
33
33
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_test"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.38"
|
|
4
4
|
description = "Shared pytest fixtures (app, client, db_session, authenticated_client) for writing simple_module module tests"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,13 +21,13 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"fastapi>=0.
|
|
24
|
+
"fastapi>=0.140,<0.157",
|
|
25
25
|
"httpx>=0.27",
|
|
26
26
|
"pytest>=8.0",
|
|
27
27
|
"pytest-asyncio>=0.24",
|
|
28
|
-
"simple_module_core==0.0.
|
|
29
|
-
"simple_module_db==0.0.
|
|
30
|
-
"simple_module_hosting==0.0.
|
|
28
|
+
"simple_module_core==0.0.38",
|
|
29
|
+
"simple_module_db==0.0.38",
|
|
30
|
+
"simple_module_hosting==0.0.38",
|
|
31
31
|
"sqlalchemy>=2.0",
|
|
32
32
|
]
|
|
33
33
|
|
|
@@ -30,6 +30,7 @@ from simple_module_test.fake_events import FakeEventBus, RecordedEvent
|
|
|
30
30
|
from simple_module_test.routes import effective_route_paths
|
|
31
31
|
from simple_module_test.session_cookie import forge_session_cookie
|
|
32
32
|
from simple_module_test.tenant_client import TenantClient
|
|
33
|
+
from simple_module_test.waiting import wait_for, wait_for_sync
|
|
33
34
|
|
|
34
35
|
__all__ = [
|
|
35
36
|
"FakeEventBus",
|
|
@@ -38,4 +39,6 @@ __all__ = [
|
|
|
38
39
|
"build_test_app",
|
|
39
40
|
"effective_route_paths",
|
|
40
41
|
"forge_session_cookie",
|
|
42
|
+
"wait_for",
|
|
43
|
+
"wait_for_sync",
|
|
41
44
|
]
|
|
@@ -19,6 +19,9 @@ from simple_module_db.base import all_module_bases
|
|
|
19
19
|
@lru_cache(maxsize=1)
|
|
20
20
|
def _ensure_models_imported() -> list:
|
|
21
21
|
"""Import all module models so all_module_bases is populated (cached)."""
|
|
22
|
+
import simple_module_db.leases # framework-owned table (#539)
|
|
23
|
+
import simple_module_db.stored_settings # noqa: F401 framework-owned table (#500)
|
|
24
|
+
|
|
22
25
|
for mod in discover_modules():
|
|
23
26
|
pkg = type(mod).__module__.split(".")[0]
|
|
24
27
|
with contextlib.suppress(ModuleNotFoundError):
|
|
@@ -51,27 +54,34 @@ async def _create_all_tables(engine) -> None:
|
|
|
51
54
|
"""
|
|
52
55
|
from sqlalchemy import text
|
|
53
56
|
|
|
54
|
-
from simple_module_test.database import
|
|
57
|
+
from simple_module_test.database import prepare_schema_once
|
|
55
58
|
|
|
56
59
|
bases = _ensure_models_imported()
|
|
57
60
|
heads = _alembic_heads()
|
|
58
|
-
await reset_schema_once(engine) # Postgres: start empty; SQLite memory already is
|
|
59
61
|
|
|
60
|
-
async
|
|
62
|
+
async def _build(engine) -> None:
|
|
63
|
+
async with engine.begin() as conn:
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
def _sync_create_all(sync_conn):
|
|
66
|
+
for base in bases:
|
|
67
|
+
base.metadata.create_all(sync_conn)
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
await conn.run_sync(_sync_create_all)
|
|
70
|
+
# Part of the build, so a reused Postgres schema's fingerprint covers it.
|
|
71
|
+
if heads:
|
|
72
|
+
await conn.execute(
|
|
73
|
+
text(
|
|
74
|
+
"CREATE TABLE IF NOT EXISTS alembic_version "
|
|
75
|
+
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
|
76
|
+
)
|
|
77
|
+
)
|
|
67
78
|
|
|
79
|
+
tables = [table for base in bases for table in base.metadata.tables.values()]
|
|
80
|
+
# Postgres reuses the schema built for an earlier test, emptied of rows.
|
|
81
|
+
await prepare_schema_once(engine, tables, _build)
|
|
82
|
+
|
|
83
|
+
async with engine.begin() as conn:
|
|
68
84
|
if heads:
|
|
69
|
-
await conn.execute(
|
|
70
|
-
text(
|
|
71
|
-
"CREATE TABLE IF NOT EXISTS alembic_version "
|
|
72
|
-
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
|
73
|
-
)
|
|
74
|
-
)
|
|
75
85
|
await conn.execute(text("DELETE FROM alembic_version"))
|
|
76
86
|
for head in heads:
|
|
77
87
|
await conn.execute(
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Which database the test fixtures run on (#343).
|
|
2
|
+
|
|
3
|
+
In-memory SQLite by default. Set ``SM_TEST_DATABASE_URL`` to an async URL
|
|
4
|
+
(``postgresql+asyncpg://postgres@localhost:5432/sm_test``) to run the same
|
|
5
|
+
suites on Postgres — what SQLite cannot show: real concurrent transactions,
|
|
6
|
+
row locks, stricter types, and SQL that SQLite happens to accept.
|
|
7
|
+
|
|
8
|
+
On Postgres every test starts with empty tables, so tests stay independent:
|
|
9
|
+
the schema is built once per process and emptied of rows between tests, and
|
|
10
|
+
dropped and rebuilt only when it changed (``prepare_schema_once``). Pooled
|
|
11
|
+
connections are disabled because pytest-asyncio
|
|
12
|
+
gives each test its own event loop. Under pytest-xdist each worker gets its
|
|
13
|
+
own database (``<name>_gw0``, ...), so workers never drop each other's schema.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from sqlalchemy import text
|
|
23
|
+
from sqlalchemy.engine import make_url
|
|
24
|
+
from sqlalchemy.pool import NullPool
|
|
25
|
+
|
|
26
|
+
TEST_DATABASE_ENV = "SM_TEST_DATABASE_URL"
|
|
27
|
+
SQLITE_MEMORY = "sqlite+aiosqlite:///:memory:"
|
|
28
|
+
XDIST_WORKER_ENV = "PYTEST_XDIST_WORKER"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def database_url_for_tests() -> str:
|
|
32
|
+
return os.environ.get(TEST_DATABASE_ENV) or SQLITE_MEMORY
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def database_is_postgres() -> bool:
|
|
36
|
+
return database_url_for_tests().startswith("postgresql")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_sqlite(url: str) -> bool:
|
|
40
|
+
return url.startswith("sqlite")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def init_db_kwargs(url: str) -> dict[str, Any]:
|
|
44
|
+
return {} if is_sqlite(url) else {"poolclass": NullPool}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def isolate_worker_database() -> None:
|
|
48
|
+
"""Point this xdist worker at its own Postgres database, creating it if needed.
|
|
49
|
+
|
|
50
|
+
Rewrites ``SM_TEST_DATABASE_URL`` rather than wrapping it, so tests that read
|
|
51
|
+
the variable directly and subprocesses that inherit it land on the same
|
|
52
|
+
database. A no-op on SQLite and outside xdist workers. The role needs
|
|
53
|
+
``CREATEDB`` the first time.
|
|
54
|
+
"""
|
|
55
|
+
worker = os.environ.get(XDIST_WORKER_ENV)
|
|
56
|
+
if not worker or not database_is_postgres():
|
|
57
|
+
return
|
|
58
|
+
base = make_url(database_url_for_tests())
|
|
59
|
+
url = base.set(database=f"{base.database}_{worker}")
|
|
60
|
+
asyncio.run(_create_database_if_missing(base, url.database or ""))
|
|
61
|
+
os.environ[TEST_DATABASE_ENV] = url.render_as_string(hide_password=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _create_database_if_missing(server_url: Any, name: str) -> None:
|
|
65
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
66
|
+
|
|
67
|
+
engine = create_async_engine(server_url, poolclass=NullPool, isolation_level="AUTOCOMMIT")
|
|
68
|
+
try:
|
|
69
|
+
async with engine.connect() as conn:
|
|
70
|
+
exists = await conn.scalar(
|
|
71
|
+
text("SELECT 1 FROM pg_database WHERE datname = :n"), {"n": name}
|
|
72
|
+
)
|
|
73
|
+
if not exists:
|
|
74
|
+
quoted = conn.dialect.identifier_preparer.quote_identifier(name)
|
|
75
|
+
await conn.execute(text(f"CREATE DATABASE {quoted}"))
|
|
76
|
+
finally:
|
|
77
|
+
await engine.dispose()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def reset_schema(engine: Any) -> None:
|
|
81
|
+
"""Empty the database before a test (Postgres); a no-op on SQLite."""
|
|
82
|
+
global _built_schema
|
|
83
|
+
if engine.dialect.name == "sqlite":
|
|
84
|
+
return
|
|
85
|
+
_built_schema = None # whatever was built before is gone
|
|
86
|
+
async with engine.begin() as conn:
|
|
87
|
+
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
|
|
88
|
+
await conn.execute(text("CREATE SCHEMA public"))
|
|
89
|
+
# The drop took the installed extensions with it; modules' models need them.
|
|
90
|
+
await conn.run_sync(_create_declared_extensions)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _create_declared_extensions(sync_conn: Any) -> None:
|
|
94
|
+
from simple_module_core.discovery import discover_modules
|
|
95
|
+
from simple_module_db.extensions import ensure_db_extensions
|
|
96
|
+
|
|
97
|
+
ensure_db_extensions(sync_conn, discover_modules())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# The test the schema was last emptied for. On SQLite every engine is its own
|
|
101
|
+
# in-memory database; on Postgres the ``app`` and ``db_session`` fixtures share
|
|
102
|
+
# one, so a second reset inside the same test would wipe the first fixture's
|
|
103
|
+
# rows (the seeded admin, say) from under it.
|
|
104
|
+
_current_test: object | None = None
|
|
105
|
+
_reset_for: object | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def begin_test(token: object) -> None:
|
|
109
|
+
"""Mark the start of a test; the next ``reset_schema_once`` resets."""
|
|
110
|
+
global _current_test
|
|
111
|
+
_current_test = token
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def reset_schema_once(engine: Any) -> None:
|
|
115
|
+
"""``reset_schema`` at most once per test (see ``begin_test``)."""
|
|
116
|
+
global _reset_for
|
|
117
|
+
if _current_test is not None and _reset_for is _current_test:
|
|
118
|
+
return
|
|
119
|
+
await reset_schema(engine)
|
|
120
|
+
_reset_for = _current_test
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# The tables this process last built and the catalog fingerprint after the
|
|
124
|
+
# build (Postgres only); reused only while both still match.
|
|
125
|
+
_built_schema: tuple[frozenset[str], str] | None = None
|
|
126
|
+
|
|
127
|
+
_FINGERPRINT = text(
|
|
128
|
+
"SELECT md5(coalesce(string_agg(item, ',' ORDER BY item), '')) FROM ("
|
|
129
|
+
" SELECT c.relname || ':' || c.relkind::text || ':' || coalesce(a.attname || ':'"
|
|
130
|
+
" || a.atttypid::text || ':' || a.attnotnull::text, '') AS item"
|
|
131
|
+
" FROM pg_class c LEFT JOIN pg_attribute a"
|
|
132
|
+
" ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped"
|
|
133
|
+
" WHERE c.relnamespace = 'public'::regnamespace"
|
|
134
|
+
" UNION ALL SELECT 'con:' || conname FROM pg_constraint"
|
|
135
|
+
" WHERE connamespace = 'public'::regnamespace"
|
|
136
|
+
" UNION ALL SELECT 'type:' || typname FROM pg_type"
|
|
137
|
+
" WHERE typnamespace = 'public'::regnamespace"
|
|
138
|
+
" UNION ALL SELECT 'ext:' || extname FROM pg_extension) AS s"
|
|
139
|
+
)
|
|
140
|
+
_RESTART_SEQUENCES = text(
|
|
141
|
+
"SELECT setval(c.oid::regclass, 1, false) FROM pg_class c"
|
|
142
|
+
" WHERE c.relkind = 'S' AND c.relnamespace = 'public'::regnamespace"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def prepare_schema_once(engine: Any, tables: list[Any], build: Any) -> None:
|
|
147
|
+
"""Give this test empty ``tables``, at most once per test (see ``begin_test``).
|
|
148
|
+
|
|
149
|
+
SQLite: ``build(engine)``, since every engine is a fresh in-memory database.
|
|
150
|
+
Postgres: when this process already built the schema and the catalog still
|
|
151
|
+
matches it, delete every row and restart the sequences (~0.03 s) instead of
|
|
152
|
+
dropping and rebuilding it (~0.25 s). Anything else (the first test, a
|
|
153
|
+
direct ``reset_schema`` call, a test's own DDL) resets and rebuilds.
|
|
154
|
+
"""
|
|
155
|
+
global _reset_for, _built_schema
|
|
156
|
+
if engine.dialect.name == "sqlite":
|
|
157
|
+
await build(engine)
|
|
158
|
+
return
|
|
159
|
+
if _current_test is not None and _reset_for is _current_test:
|
|
160
|
+
return
|
|
161
|
+
_reset_for = _current_test
|
|
162
|
+
names = frozenset(table.fullname for table in tables)
|
|
163
|
+
if _built_schema is not None and _built_schema == (names, await _fingerprint(engine)):
|
|
164
|
+
await _empty_tables(engine, tables)
|
|
165
|
+
return
|
|
166
|
+
await reset_schema(engine)
|
|
167
|
+
await build(engine)
|
|
168
|
+
_built_schema = (names, await _fingerprint(engine))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _fingerprint(engine: Any) -> str:
|
|
172
|
+
async with engine.connect() as conn:
|
|
173
|
+
return await conn.scalar(_FINGERPRINT)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _empty_tables(engine: Any, tables: list[Any]) -> None:
|
|
177
|
+
from sqlalchemy.schema import sort_tables
|
|
178
|
+
|
|
179
|
+
async with engine.begin() as conn:
|
|
180
|
+
quote = conn.dialect.identifier_preparer.format_table
|
|
181
|
+
# Children first, so no foreign key refuses a parent's delete.
|
|
182
|
+
for table in reversed(sort_tables(tables)):
|
|
183
|
+
await conn.execute(text(f"DELETE FROM {quote(table)}"))
|
|
184
|
+
await conn.execute(_RESTART_SEQUENCES)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = [
|
|
188
|
+
"SQLITE_MEMORY",
|
|
189
|
+
"TEST_DATABASE_ENV",
|
|
190
|
+
"begin_test",
|
|
191
|
+
"database_is_postgres",
|
|
192
|
+
"database_url_for_tests",
|
|
193
|
+
"init_db_kwargs",
|
|
194
|
+
"is_sqlite",
|
|
195
|
+
"isolate_worker_database",
|
|
196
|
+
"prepare_schema_once",
|
|
197
|
+
"reset_schema",
|
|
198
|
+
"reset_schema_once",
|
|
199
|
+
]
|
|
@@ -32,7 +32,7 @@ class FakeEventBus(EventBus):
|
|
|
32
32
|
super().__init__()
|
|
33
33
|
self.events: list[RecordedEvent] = []
|
|
34
34
|
|
|
35
|
-
async def publish(self, event: Event) -> None:
|
|
35
|
+
async def publish(self, event: Event) -> None:
|
|
36
36
|
self.events.append(RecordedEvent(event=event))
|
|
37
37
|
return await super().publish(event)
|
|
38
38
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Mail fixtures: capture what the app sends, and never leak a sender between tests.
|
|
2
|
+
|
|
3
|
+
The process mail sender (``simple_module_core.mail``) is module-level state that
|
|
4
|
+
an app's ``mail`` module installs on startup, so a test that boots an app would
|
|
5
|
+
otherwise hand its SMTP sender to the next test in the same worker.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from simple_module_core import mail as core_mail
|
|
14
|
+
from simple_module_core.mail import MailMessage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CapturedMail:
|
|
18
|
+
"""A sender that records every message instead of sending it."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *, delivers_email: bool = True) -> None:
|
|
21
|
+
self.delivers_email = delivers_email
|
|
22
|
+
self.messages: list[MailMessage] = []
|
|
23
|
+
|
|
24
|
+
async def send(self, message: MailMessage) -> None:
|
|
25
|
+
self.messages.append(message)
|
|
26
|
+
|
|
27
|
+
def to(self, address: str) -> list[MailMessage]:
|
|
28
|
+
return [m for m in self.messages if m.to == address]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(autouse=True)
|
|
32
|
+
def _reset_process_mail_sender() -> Iterator[None]:
|
|
33
|
+
sender, resolver = core_mail._sender, core_mail._resolver
|
|
34
|
+
yield
|
|
35
|
+
core_mail.install_mail_sender(sender)
|
|
36
|
+
core_mail.install_mail_resolver(resolver)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def captured_mail() -> Iterator[CapturedMail]:
|
|
41
|
+
"""Install a recording sender that claims to deliver; yields it.
|
|
42
|
+
|
|
43
|
+
Installed after any app fixture requested before it has started, so it wins
|
|
44
|
+
over the ``mail`` module's sender. A ``settings_reloaded`` in the test
|
|
45
|
+
reinstalls the real one; reinstall with ``install_mail_sender(captured)``.
|
|
46
|
+
"""
|
|
47
|
+
captured = CapturedMail()
|
|
48
|
+
core_mail.install_mail_sender(captured)
|
|
49
|
+
yield captured
|
|
@@ -33,10 +33,45 @@ from simple_module_test.fixtures import ( # noqa: F401
|
|
|
33
33
|
settings,
|
|
34
34
|
setup_pending_app,
|
|
35
35
|
)
|
|
36
|
+
from simple_module_test.mail import _reset_process_mail_sender, captured_mail # noqa: F401
|
|
36
37
|
from simple_module_test.redis_server import redis_server # noqa: F401
|
|
37
38
|
from simple_module_test.tenant_client import tenant_client # noqa: F401
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
42
|
+
config.addinivalue_line(
|
|
43
|
+
"markers",
|
|
44
|
+
"postgres: needs a PostgreSQL test database (SM_TEST_DATABASE_URL=postgresql+...)",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.hookimpl(tryfirst=True)
|
|
49
|
+
def pytest_load_initial_conftests(early_config, parser, args) -> None:
|
|
50
|
+
"""Give each xdist worker its own Postgres database.
|
|
51
|
+
|
|
52
|
+
Every Postgres test drops and recreates ``public``. This runs before any
|
|
53
|
+
conftest is imported (``pytest_configure`` runs after), so a conftest or
|
|
54
|
+
test helper that reads the URL at import time already sees the worker's.
|
|
55
|
+
"""
|
|
56
|
+
from simple_module_test.database import isolate_worker_database
|
|
57
|
+
|
|
58
|
+
isolate_worker_database()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
|
62
|
+
"""Skip ``@pytest.mark.postgres`` tests unless the test database is Postgres."""
|
|
63
|
+
from simple_module_test.database import database_is_postgres
|
|
64
|
+
|
|
65
|
+
if database_is_postgres():
|
|
66
|
+
return
|
|
67
|
+
skip = pytest.mark.skip(
|
|
68
|
+
reason="needs Postgres: set SM_TEST_DATABASE_URL=postgresql+asyncpg://..."
|
|
69
|
+
)
|
|
70
|
+
for item in items:
|
|
71
|
+
if "postgres" in item.keywords:
|
|
72
|
+
item.add_marker(skip)
|
|
73
|
+
|
|
74
|
+
|
|
40
75
|
def _bootstrap_eager_celery() -> None:
|
|
41
76
|
"""Register a process-wide eager Celery app before any test runs.
|
|
42
77
|
|
|
@@ -65,6 +100,49 @@ def _bootstrap_eager_celery() -> None:
|
|
|
65
100
|
_bootstrap_eager_celery()
|
|
66
101
|
|
|
67
102
|
|
|
103
|
+
def _cheap_password_hashing() -> None:
|
|
104
|
+
"""Make ``PasswordHelper()`` hash with minimal Argon2/bcrypt cost.
|
|
105
|
+
|
|
106
|
+
Every ``app`` fixture seeds an admin, and production-cost Argon2 is ~90ms
|
|
107
|
+
of CPU per hash — over a tenth of each app's setup, paid thousands of times
|
|
108
|
+
a run. The hashes stay real ``$argon2``/``$2b`` strings that verify and
|
|
109
|
+
upgrade through the same code paths; only their work factor drops, which is
|
|
110
|
+
a property of deployment rather than anything a test asserts.
|
|
111
|
+
|
|
112
|
+
Patched at import, not in a fixture: test modules build ``PasswordHelper()``
|
|
113
|
+
at module scope during collection, before any fixture runs. An explicit
|
|
114
|
+
``password_hash=`` still wins. Skipped when ``fastapi_users`` is absent, or
|
|
115
|
+
when ``SM_TEST_REAL_PASSWORD_HASHING`` is set — the opt-out for a consumer
|
|
116
|
+
suite whose tests depend on production hash parameters.
|
|
117
|
+
"""
|
|
118
|
+
if os.environ.get("SM_TEST_REAL_PASSWORD_HASHING"):
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
from fastapi_users.password import PasswordHelper
|
|
122
|
+
from pwdlib import PasswordHash
|
|
123
|
+
from pwdlib.hashers.argon2 import Argon2Hasher
|
|
124
|
+
from pwdlib.hashers.bcrypt import BcryptHasher
|
|
125
|
+
except ImportError:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
original_init = PasswordHelper.__init__
|
|
129
|
+
|
|
130
|
+
def _init(self, password_hash: PasswordHash | None = None) -> None:
|
|
131
|
+
if password_hash is None:
|
|
132
|
+
password_hash = PasswordHash(
|
|
133
|
+
(
|
|
134
|
+
Argon2Hasher(time_cost=1, memory_cost=8, parallelism=1),
|
|
135
|
+
BcryptHasher(rounds=4),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
original_init(self, password_hash)
|
|
139
|
+
|
|
140
|
+
PasswordHelper.__init__ = _init
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_cheap_password_hashing()
|
|
144
|
+
|
|
145
|
+
|
|
68
146
|
@pytest.fixture
|
|
69
147
|
def fake_event_bus() -> FakeEventBus:
|
|
70
148
|
"""Fresh recording EventBus for each test."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Polling helpers that replace fixed ``sleep()`` waits in tests.
|
|
2
|
+
|
|
3
|
+
A fixed sleep is a bet on timing: too short and the test flakes on a loaded
|
|
4
|
+
runner, too long and the suite is slow on a fast one. ``wait_for`` polls a
|
|
5
|
+
predicate until it is truthy and fails with the last value it saw.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import inspect
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _timeout_error(
|
|
16
|
+
predicate: Callable[..., Any], timeout: float, last: Any, error: BaseException | None
|
|
17
|
+
) -> AssertionError:
|
|
18
|
+
name = getattr(predicate, "__name__", repr(predicate))
|
|
19
|
+
detail = f"last error: {error!r}" if error is not None else f"last value: {last!r}"
|
|
20
|
+
return AssertionError(f"wait_for({name}) not satisfied within {timeout}s; {detail}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _poll_once(predicate: Callable[[], Any]) -> tuple[Any, BaseException | None]:
|
|
24
|
+
"""Call ``predicate`` once; an exception it raises is returned, not raised."""
|
|
25
|
+
try:
|
|
26
|
+
return predicate(), None
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
return None, exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def wait_for(
|
|
32
|
+
predicate: Callable[[], Any] | Callable[[], Awaitable[Any]],
|
|
33
|
+
timeout: float = 2.0,
|
|
34
|
+
interval: float = 0.01,
|
|
35
|
+
) -> Any:
|
|
36
|
+
"""Poll ``predicate`` (sync or async) until truthy; return its value.
|
|
37
|
+
|
|
38
|
+
Raises ``AssertionError`` with the last value seen on timeout. An exception
|
|
39
|
+
raised by the predicate counts as "not yet" and is reported if it is the
|
|
40
|
+
last thing seen.
|
|
41
|
+
"""
|
|
42
|
+
deadline = time.monotonic() + timeout
|
|
43
|
+
while True:
|
|
44
|
+
value, error = _poll_once(predicate)
|
|
45
|
+
if inspect.isawaitable(value):
|
|
46
|
+
value, error = await _settle(value, max(deadline - time.monotonic(), 0.001))
|
|
47
|
+
if value:
|
|
48
|
+
return value
|
|
49
|
+
if time.monotonic() >= deadline:
|
|
50
|
+
raise _timeout_error(predicate, timeout, value, error)
|
|
51
|
+
await asyncio.sleep(interval)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _settle(awaitable: Awaitable[Any], budget: float) -> tuple[Any, BaseException | None]:
|
|
55
|
+
"""Await an async predicate's result, with :func:`_poll_once`'s contract.
|
|
56
|
+
|
|
57
|
+
``budget`` bounds the await, so a predicate that hangs fails the wait at its
|
|
58
|
+
timeout instead of blocking the test forever.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
return await asyncio.wait_for(awaitable, budget), None
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
return None, exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def wait_for_sync(
|
|
67
|
+
predicate: Callable[[], Any],
|
|
68
|
+
timeout: float = 2.0,
|
|
69
|
+
interval: float = 0.01,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Blocking twin of :func:`wait_for` for sync tests (sync predicates only)."""
|
|
72
|
+
deadline = time.monotonic() + timeout
|
|
73
|
+
while True:
|
|
74
|
+
value, error = _poll_once(predicate)
|
|
75
|
+
if inspect.isawaitable(value):
|
|
76
|
+
if inspect.iscoroutine(value):
|
|
77
|
+
value.close() # never awaited: avoid the RuntimeWarning
|
|
78
|
+
raise TypeError("wait_for_sync takes a sync predicate; use wait_for for async ones")
|
|
79
|
+
if value:
|
|
80
|
+
return value
|
|
81
|
+
if time.monotonic() >= deadline:
|
|
82
|
+
raise _timeout_error(predicate, timeout, value, error)
|
|
83
|
+
time.sleep(interval) # intentional-sleep: the polling interval itself
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Postgres reuses the schema between tests, emptied of rows (``prepare_schema_once``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from simple_module_test import database
|
|
7
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, func, select, text
|
|
8
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
9
|
+
|
|
10
|
+
pytestmark = pytest.mark.postgres
|
|
11
|
+
|
|
12
|
+
_metadata = MetaData()
|
|
13
|
+
_item = Table(
|
|
14
|
+
"reuse_probe_item",
|
|
15
|
+
_metadata,
|
|
16
|
+
Column("id", Integer, primary_key=True),
|
|
17
|
+
Column("name", String(20)),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
async def engine(monkeypatch):
|
|
23
|
+
# The probe drives the per-test state itself; put it back afterwards.
|
|
24
|
+
for attr in ("_current_test", "_reset_for", "_built_schema"):
|
|
25
|
+
monkeypatch.setattr(database, attr, getattr(database, attr))
|
|
26
|
+
url = database.database_url_for_tests()
|
|
27
|
+
engine = create_async_engine(url, **database.init_db_kwargs(url))
|
|
28
|
+
try:
|
|
29
|
+
yield engine
|
|
30
|
+
finally:
|
|
31
|
+
await database.reset_schema(engine) # the next test rebuilds the real schema
|
|
32
|
+
await engine.dispose()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _next_test(engine, builds: list[int]) -> None:
|
|
36
|
+
async def build(engine) -> None:
|
|
37
|
+
builds.append(1)
|
|
38
|
+
async with engine.begin() as conn:
|
|
39
|
+
await conn.run_sync(_metadata.create_all)
|
|
40
|
+
|
|
41
|
+
database.begin_test(object())
|
|
42
|
+
await database.prepare_schema_once(engine, [_item], build)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _insert(engine) -> int:
|
|
46
|
+
async with engine.begin() as conn:
|
|
47
|
+
return (
|
|
48
|
+
await conn.execute(_item.insert().values(name="x").returning(_item.c.id))
|
|
49
|
+
).scalar_one()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def test_a_later_test_gets_the_same_schema_without_rows(engine):
|
|
53
|
+
builds: list[int] = []
|
|
54
|
+
await _next_test(engine, builds)
|
|
55
|
+
assert [await _insert(engine), await _insert(engine)] == [1, 2]
|
|
56
|
+
|
|
57
|
+
await _next_test(engine, builds)
|
|
58
|
+
|
|
59
|
+
assert builds == [1]
|
|
60
|
+
async with engine.connect() as conn:
|
|
61
|
+
assert await conn.scalar(select(func.count()).select_from(_item)) == 0
|
|
62
|
+
assert await _insert(engine) == 1 # sequences restart too
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def test_a_test_that_changes_the_schema_forces_a_rebuild(engine):
|
|
66
|
+
builds: list[int] = []
|
|
67
|
+
await _next_test(engine, builds)
|
|
68
|
+
async with engine.begin() as conn:
|
|
69
|
+
await conn.execute(text("CREATE TABLE reuse_probe_extra (x int)"))
|
|
70
|
+
|
|
71
|
+
await _next_test(engine, builds)
|
|
72
|
+
|
|
73
|
+
assert builds == [1, 1]
|
|
74
|
+
async with engine.connect() as conn:
|
|
75
|
+
assert await conn.scalar(text("SELECT to_regclass('reuse_probe_extra')")) is None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from simple_module_test import wait_for, wait_for_sync
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def test_returns_value_when_already_true():
|
|
8
|
+
assert await wait_for(lambda: "ok") == "ok"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def test_polls_sync_predicate_until_true():
|
|
12
|
+
calls = {"n": 0}
|
|
13
|
+
|
|
14
|
+
def pred():
|
|
15
|
+
calls["n"] += 1
|
|
16
|
+
return calls["n"] >= 3
|
|
17
|
+
|
|
18
|
+
assert await wait_for(pred, interval=0.001) is True
|
|
19
|
+
assert calls["n"] == 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_async_predicate():
|
|
23
|
+
state = {"done": False}
|
|
24
|
+
|
|
25
|
+
async def pred():
|
|
26
|
+
return state["done"]
|
|
27
|
+
|
|
28
|
+
async def flip():
|
|
29
|
+
await asyncio.sleep(0.02) # intentional-sleep: simulate work finishing later
|
|
30
|
+
state["done"] = True
|
|
31
|
+
|
|
32
|
+
task = asyncio.create_task(flip())
|
|
33
|
+
assert await wait_for(pred, interval=0.005) is True
|
|
34
|
+
await task
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def test_timeout_reports_last_value():
|
|
38
|
+
with pytest.raises(AssertionError, match=r"last value: 0"):
|
|
39
|
+
await wait_for(lambda: 0, timeout=0.05, interval=0.005)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def test_predicate_exception_is_retried_then_reported():
|
|
43
|
+
calls = {"n": 0}
|
|
44
|
+
|
|
45
|
+
def pred():
|
|
46
|
+
calls["n"] += 1
|
|
47
|
+
if calls["n"] < 3:
|
|
48
|
+
raise KeyError("missing")
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
assert await wait_for(pred, interval=0.001)
|
|
52
|
+
|
|
53
|
+
def boom():
|
|
54
|
+
raise ValueError("x")
|
|
55
|
+
|
|
56
|
+
with pytest.raises(AssertionError, match="last error: ValueError"):
|
|
57
|
+
await wait_for(boom, timeout=0.05, interval=0.005)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_sync_returns_and_polls():
|
|
61
|
+
calls = {"n": 0}
|
|
62
|
+
|
|
63
|
+
def pred():
|
|
64
|
+
calls["n"] += 1
|
|
65
|
+
return calls["n"] >= 2
|
|
66
|
+
|
|
67
|
+
assert wait_for_sync(pred, interval=0.001) is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_sync_timeout():
|
|
71
|
+
with pytest.raises(AssertionError, match=r"last value: \[\]"):
|
|
72
|
+
wait_for_sync(list, timeout=0.05, interval=0.005)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_hung_async_predicate_times_out():
|
|
76
|
+
async def hangs():
|
|
77
|
+
await asyncio.sleep(30) # intentional-sleep: never returns on its own
|
|
78
|
+
|
|
79
|
+
with pytest.raises(AssertionError, match="not satisfied"):
|
|
80
|
+
await wait_for(hangs, timeout=0.05, interval=0.005)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_sync_rejects_an_async_predicate():
|
|
84
|
+
async def pred():
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
with pytest.raises(TypeError, match="sync predicate"):
|
|
88
|
+
wait_for_sync(pred)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Each xdist worker gets its own Postgres database (``isolate_worker_database``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from simple_module_test import database
|
|
7
|
+
|
|
8
|
+
_PG = "postgresql+asyncpg://postgres:secret@db.local:5432/sm_test"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def created(monkeypatch) -> list[tuple[str, str]]:
|
|
13
|
+
calls: list[tuple[str, str]] = []
|
|
14
|
+
|
|
15
|
+
async def fake_create(server_url, name):
|
|
16
|
+
calls.append((server_url.database, name))
|
|
17
|
+
|
|
18
|
+
monkeypatch.setattr(database, "_create_database_if_missing", fake_create)
|
|
19
|
+
return calls
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_a_worker_is_pointed_at_its_own_database(monkeypatch, created):
|
|
23
|
+
monkeypatch.setenv(database.TEST_DATABASE_ENV, _PG)
|
|
24
|
+
monkeypatch.setenv(database.XDIST_WORKER_ENV, "gw3")
|
|
25
|
+
|
|
26
|
+
database.isolate_worker_database()
|
|
27
|
+
|
|
28
|
+
assert created == [("sm_test", "sm_test_gw3")]
|
|
29
|
+
assert database.database_url_for_tests() == (
|
|
30
|
+
"postgresql+asyncpg://postgres:secret@db.local:5432/sm_test_gw3"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_outside_xdist_the_named_database_is_used(monkeypatch, created):
|
|
35
|
+
monkeypatch.setenv(database.TEST_DATABASE_ENV, _PG)
|
|
36
|
+
monkeypatch.delenv(database.XDIST_WORKER_ENV, raising=False)
|
|
37
|
+
|
|
38
|
+
database.isolate_worker_database()
|
|
39
|
+
|
|
40
|
+
assert created == []
|
|
41
|
+
assert database.database_url_for_tests() == _PG
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_sqlite_workers_are_left_alone(monkeypatch, created):
|
|
45
|
+
monkeypatch.delenv(database.TEST_DATABASE_ENV, raising=False)
|
|
46
|
+
monkeypatch.setenv(database.XDIST_WORKER_ENV, "gw0")
|
|
47
|
+
|
|
48
|
+
database.isolate_worker_database()
|
|
49
|
+
|
|
50
|
+
assert created == []
|
|
51
|
+
assert database.database_url_for_tests() == database.SQLITE_MEMORY
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""Which database the test fixtures run on (#343).
|
|
2
|
-
|
|
3
|
-
In-memory SQLite by default. Set ``SM_TEST_DATABASE_URL`` to an async URL
|
|
4
|
-
(``postgresql+asyncpg://postgres@localhost:5432/sm_test``) to run the same
|
|
5
|
-
suites on Postgres — what SQLite cannot show: real concurrent transactions,
|
|
6
|
-
row locks, stricter types, and SQL that SQLite happens to accept.
|
|
7
|
-
|
|
8
|
-
On Postgres every fixture starts from an empty ``public`` schema, so tests
|
|
9
|
-
stay independent; pooled connections are disabled because pytest-asyncio
|
|
10
|
-
gives each test its own event loop.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import os
|
|
16
|
-
from typing import Any
|
|
17
|
-
|
|
18
|
-
from sqlalchemy import text
|
|
19
|
-
from sqlalchemy.pool import NullPool
|
|
20
|
-
|
|
21
|
-
TEST_DATABASE_ENV = "SM_TEST_DATABASE_URL"
|
|
22
|
-
SQLITE_MEMORY = "sqlite+aiosqlite:///:memory:"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def database_url_for_tests() -> str:
|
|
26
|
-
return os.environ.get(TEST_DATABASE_ENV) or SQLITE_MEMORY
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def is_sqlite(url: str) -> bool:
|
|
30
|
-
return url.startswith("sqlite")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def init_db_kwargs(url: str) -> dict[str, Any]:
|
|
34
|
-
return {} if is_sqlite(url) else {"poolclass": NullPool}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
async def reset_schema(engine: Any) -> None:
|
|
38
|
-
"""Empty the database before a test (Postgres); a no-op on SQLite."""
|
|
39
|
-
if engine.dialect.name == "sqlite":
|
|
40
|
-
return
|
|
41
|
-
async with engine.begin() as conn:
|
|
42
|
-
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
|
|
43
|
-
await conn.execute(text("CREATE SCHEMA public"))
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# The test the schema was last emptied for. On SQLite every engine is its own
|
|
47
|
-
# in-memory database; on Postgres the ``app`` and ``db_session`` fixtures share
|
|
48
|
-
# one, so a second reset inside the same test would wipe the first fixture's
|
|
49
|
-
# rows (the seeded admin, say) from under it.
|
|
50
|
-
_current_test: object | None = None
|
|
51
|
-
_reset_for: object | None = None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def begin_test(token: object) -> None:
|
|
55
|
-
"""Mark the start of a test; the next ``reset_schema_once`` resets."""
|
|
56
|
-
global _current_test
|
|
57
|
-
_current_test = token
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
async def reset_schema_once(engine: Any) -> None:
|
|
61
|
-
"""``reset_schema`` at most once per test (see ``begin_test``)."""
|
|
62
|
-
global _reset_for
|
|
63
|
-
if _current_test is not None and _reset_for is _current_test:
|
|
64
|
-
return
|
|
65
|
-
await reset_schema(engine)
|
|
66
|
-
_reset_for = _current_test
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
__all__ = [
|
|
70
|
-
"SQLITE_MEMORY",
|
|
71
|
-
"TEST_DATABASE_ENV",
|
|
72
|
-
"begin_test",
|
|
73
|
-
"database_url_for_tests",
|
|
74
|
-
"init_db_kwargs",
|
|
75
|
-
"is_sqlite",
|
|
76
|
-
"reset_schema",
|
|
77
|
-
"reset_schema_once",
|
|
78
|
-
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/session_cookie.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|