simple-module-test 0.0.37__tar.gz → 0.0.39__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 (28) hide show
  1. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/.gitignore +1 -2
  2. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/PKG-INFO +9 -9
  3. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/pyproject.toml +9 -9
  4. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/__init__.py +3 -0
  5. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/_schema.py +23 -13
  6. simple_module_test-0.0.39/simple_module_test/database.py +199 -0
  7. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/fake_events.py +1 -1
  8. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/fixtures.py +3 -0
  9. simple_module_test-0.0.39/simple_module_test/mail.py +40 -0
  10. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/plugin.py +15 -9
  11. simple_module_test-0.0.39/simple_module_test/process_state.py +24 -0
  12. simple_module_test-0.0.39/simple_module_test/waiting.py +83 -0
  13. simple_module_test-0.0.39/tests/test_schema_reuse_pg.py +75 -0
  14. simple_module_test-0.0.39/tests/test_testing_process_state.py +94 -0
  15. simple_module_test-0.0.39/tests/test_waiting_helpers.py +88 -0
  16. simple_module_test-0.0.39/tests/test_worker_database.py +51 -0
  17. simple_module_test-0.0.37/simple_module_test/database.py +0 -92
  18. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/LICENSE +0 -0
  19. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/README.md +0 -0
  20. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/app_factory.py +0 -0
  21. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/redis_server.py +0 -0
  22. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/routes.py +0 -0
  23. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/session_cookie.py +0 -0
  24. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/setup_wizard.py +0 -0
  25. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/simple_module_test/tenant_client.py +0 -0
  26. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/tests/test_app_factory.py +0 -0
  27. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/tests/test_fake_events.py +0 -0
  28. {simple_module_test-0.0.37 → simple_module_test-0.0.39}/tests/test_plugin_fixtures.py +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -65,3 +63,4 @@ host/client_app/.playwright-cli/*
65
63
  .qa/
66
64
  .verify/
67
65
  /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_test
3
- Version: 0.0.37
3
+ Version: 0.0.39
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,14 +21,14 @@ 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>=0.115
25
- Requires-Dist: httpx>=0.27
26
- Requires-Dist: pytest-asyncio>=0.24
27
- Requires-Dist: pytest>=8.0
28
- Requires-Dist: simple-module-core==0.0.37
29
- Requires-Dist: simple-module-db==0.0.37
30
- Requires-Dist: simple-module-hosting==0.0.37
31
- Requires-Dist: sqlalchemy>=2.0
24
+ Requires-Dist: fastapi<0.157,>=0.140
25
+ Requires-Dist: httpx<0.29,>=0.27
26
+ Requires-Dist: pytest-asyncio<2,>=0.24
27
+ Requires-Dist: pytest<10,>=8.0
28
+ Requires-Dist: simple-module-core==0.0.39
29
+ Requires-Dist: simple-module-db==0.0.39
30
+ Requires-Dist: simple-module-hosting==0.0.39
31
+ Requires-Dist: sqlalchemy<3,>=2.0
32
32
  Description-Content-Type: text/markdown
33
33
 
34
34
  # simple_module_test
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_test"
3
- version = "0.0.37"
3
+ version = "0.0.39"
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,14 +21,14 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "fastapi>=0.115",
25
- "httpx>=0.27",
26
- "pytest>=8.0",
27
- "pytest-asyncio>=0.24",
28
- "simple_module_core==0.0.37",
29
- "simple_module_db==0.0.37",
30
- "simple_module_hosting==0.0.37",
31
- "sqlalchemy>=2.0",
24
+ "fastapi>=0.140,<0.157",
25
+ "httpx>=0.27,<0.29",
26
+ "pytest>=8.0,<10",
27
+ "pytest-asyncio>=0.24,<2",
28
+ "simple_module_core==0.0.39",
29
+ "simple_module_db==0.0.39",
30
+ "simple_module_hosting==0.0.39",
31
+ "sqlalchemy>=2.0,<3",
32
32
  ]
33
33
 
34
34
  [project.entry-points.pytest11]
@@ -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 reset_schema_once
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 with engine.begin() as conn:
62
+ async def _build(engine) -> None:
63
+ async with engine.begin() as conn:
61
64
 
62
- def _sync_create_all(sync_conn):
63
- for base in bases:
64
- base.metadata.create_all(sync_conn)
65
+ def _sync_create_all(sync_conn):
66
+ for base in bases:
67
+ base.metadata.create_all(sync_conn)
65
68
 
66
- await conn.run_sync(_sync_create_all)
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: # type: ignore[override]
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
 
@@ -161,6 +161,9 @@ async def _seed_setup_admin(application) -> None:
161
161
  async def _build_app(settings: Settings, *, seed_admin: bool):
162
162
  from simple_module_hosting.app_builder import create_app
163
163
 
164
+ # Process-wide state is reset per test by the autouse fixture in
165
+ # ``process_state``, not here: a second app built inside one test must not
166
+ # reset the live first app's mail sender or lease backend.
164
167
  application = create_app(settings)
165
168
 
166
169
  await _create_all_tables(application.state.sm.db.engine)
@@ -0,0 +1,40 @@
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.mail import MailMessage, install_mail_sender
14
+
15
+
16
+ class CapturedMail:
17
+ """A sender that records every message instead of sending it."""
18
+
19
+ def __init__(self, *, delivers_email: bool = True) -> None:
20
+ self.delivers_email = delivers_email
21
+ self.messages: list[MailMessage] = []
22
+
23
+ async def send(self, message: MailMessage) -> None:
24
+ self.messages.append(message)
25
+
26
+ def to(self, address: str) -> list[MailMessage]:
27
+ return [m for m in self.messages if m.to == address]
28
+
29
+
30
+ @pytest.fixture
31
+ def captured_mail() -> Iterator[CapturedMail]:
32
+ """Install a recording sender that claims to deliver; yields it.
33
+
34
+ Installed after any app fixture requested before it has started, so it wins
35
+ over the ``mail`` module's sender. A ``settings_reloaded`` in the test
36
+ reinstalls the real one; reinstall with ``install_mail_sender(captured)``.
37
+ """
38
+ captured = CapturedMail()
39
+ install_mail_sender(captured)
40
+ yield captured
@@ -33,6 +33,8 @@ 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 captured_mail # noqa: F401
37
+ from simple_module_test.process_state import _reset_process_state # noqa: F401
36
38
  from simple_module_test.redis_server import redis_server # noqa: F401
37
39
  from simple_module_test.tenant_client import tenant_client # noqa: F401
38
40
 
@@ -42,15 +44,19 @@ def pytest_configure(config: pytest.Config) -> None:
42
44
  "markers",
43
45
  "postgres: needs a PostgreSQL test database (SM_TEST_DATABASE_URL=postgresql+...)",
44
46
  )
45
- from simple_module_test.database import database_is_postgres
46
47
 
47
- # Every Postgres test drops and recreates ``public`` in the one database, so
48
- # xdist workers would wipe each other's schema mid-test.
49
- if database_is_postgres() and getattr(config.option, "numprocesses", None):
50
- raise pytest.UsageError(
51
- "SM_TEST_DATABASE_URL is Postgres, whose schema every test drops: run "
52
- "serially with `make test-py-pg` or `-n 0` (`PYTEST_WORKERS=0 make test-py`)"
53
- )
48
+
49
+ @pytest.hookimpl(tryfirst=True)
50
+ def pytest_load_initial_conftests(early_config, parser, args) -> None:
51
+ """Give each xdist worker its own Postgres database.
52
+
53
+ Every Postgres test drops and recreates ``public``. This runs before any
54
+ conftest is imported (``pytest_configure`` runs after), so a conftest or
55
+ test helper that reads the URL at import time already sees the worker's.
56
+ """
57
+ from simple_module_test.database import isolate_worker_database
58
+
59
+ isolate_worker_database()
54
60
 
55
61
 
56
62
  def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
@@ -132,7 +138,7 @@ def _cheap_password_hashing() -> None:
132
138
  )
133
139
  original_init(self, password_hash)
134
140
 
135
- PasswordHelper.__init__ = _init # type: ignore[method-assign]
141
+ PasswordHelper.__init__ = _init
136
142
 
137
143
 
138
144
  _cheap_password_hashing()
@@ -0,0 +1,24 @@
1
+ """Reset process services between tests and apps built by the test plugin."""
2
+
3
+ from collections.abc import Iterator
4
+
5
+ import pytest
6
+ from simple_module_core.leases import reset_lease_backend
7
+ from simple_module_core.mail import reset_mail_sender
8
+ from simple_module_hosting.sentry import reset_sentry
9
+
10
+
11
+ def reset_process_state() -> None:
12
+ """Restore unconfigured services without retaining another app's resources."""
13
+ reset_mail_sender()
14
+ reset_lease_backend()
15
+ reset_sentry()
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def _reset_process_state() -> Iterator[None]:
20
+ reset_process_state()
21
+ try:
22
+ yield
23
+ finally:
24
+ reset_process_state()
@@ -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,94 @@
1
+ """Process services are isolated between plugin-built apps and tests."""
2
+
3
+ from unittest.mock import Mock
4
+
5
+ from simple_module_core.leases import (
6
+ InProcessLeaseBackend,
7
+ configure_leases,
8
+ get_leases,
9
+ reset_lease_backend,
10
+ )
11
+ from simple_module_core.mail import (
12
+ ConsoleMailSender,
13
+ current_mail_sender,
14
+ install_mail_resolver,
15
+ install_mail_sender,
16
+ mail_transport_installed,
17
+ reset_mail_sender,
18
+ resolve_mail_sender,
19
+ )
20
+ from simple_module_hosting import sentry
21
+ from simple_module_test.mail import CapturedMail
22
+ from simple_module_test.process_state import reset_process_state
23
+
24
+
25
+ async def test_reset_mail_clears_sender_and_worker_resolver() -> None:
26
+ sender = CapturedMail()
27
+
28
+ async def resolver(db):
29
+ return sender
30
+
31
+ install_mail_sender(sender)
32
+ install_mail_resolver(resolver)
33
+ reset_mail_sender()
34
+ assert not mail_transport_installed()
35
+ assert isinstance(current_mail_sender(), ConsoleMailSender)
36
+ assert isinstance(await resolve_mail_sender(object()), ConsoleMailSender)
37
+ assert current_mail_sender().include_body is False
38
+
39
+
40
+ async def test_reset_leases_discards_configuration_and_held_names() -> None:
41
+ original = InProcessLeaseBackend()
42
+ configure_leases(original)
43
+ assert await original.acquire("held", "previous-app", 60) == 1
44
+ reset_lease_backend()
45
+ replacement = get_leases()
46
+ assert replacement is not original
47
+ assert isinstance(replacement, InProcessLeaseBackend)
48
+ assert await replacement.acquire("held", "next-app", 60) == 1
49
+
50
+
51
+ def test_reset_sentry_closes_and_unbinds_client(monkeypatch) -> None:
52
+ import pytest
53
+
54
+ sdk = pytest.importorskip("sentry_sdk")
55
+ scope = Mock()
56
+ monkeypatch.setattr(sdk, "get_global_scope", lambda: scope)
57
+ monkeypatch.setattr(sentry, "_enabled", True)
58
+ sentry._request_scope.set({"type": "http"})
59
+ sentry.reset_sentry()
60
+ scope.client.close.assert_called_once_with()
61
+ scope.set_client.assert_called_once_with(None)
62
+ assert not sentry.is_enabled()
63
+ assert sentry._request_scope.get() is None
64
+ sentry.reset_sentry()
65
+ scope.client.close.assert_called_once_with()
66
+
67
+
68
+ async def test_plugin_second_app_keeps_first_apps_services(settings, monkeypatch) -> None:
69
+ """Resets are per test (autouse fixture), so a second app in one test must not
70
+ wipe the first app's installed mail sender."""
71
+ from fastapi import FastAPI
72
+ from simple_module_hosting import app_builder
73
+ from simple_module_test import fixtures
74
+
75
+ def create_app(settings):
76
+ app = FastAPI()
77
+ app.state.sm = Mock()
78
+ return app
79
+
80
+ async def create_tables(engine):
81
+ pass
82
+
83
+ monkeypatch.setattr(app_builder, "create_app", create_app)
84
+ monkeypatch.setattr(fixtures, "_create_all_tables", create_tables)
85
+ install_mail_sender(CapturedMail())
86
+ async for _app in fixtures._build_app(settings, seed_admin=False):
87
+ assert mail_transport_installed()
88
+ reset_process_state()
89
+
90
+
91
+ def test_autouse_reset_clears_state_left_by_a_previous_test() -> None:
92
+ # The autouse ``_reset_process_state`` fixture ran before this test.
93
+ assert not mail_transport_installed()
94
+ assert not sentry.is_enabled()
@@ -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,92 +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 database_is_postgres() -> bool:
30
- return database_url_for_tests().startswith("postgresql")
31
-
32
-
33
- def is_sqlite(url: str) -> bool:
34
- return url.startswith("sqlite")
35
-
36
-
37
- def init_db_kwargs(url: str) -> dict[str, Any]:
38
- return {} if is_sqlite(url) else {"poolclass": NullPool}
39
-
40
-
41
- async def reset_schema(engine: Any) -> None:
42
- """Empty the database before a test (Postgres); a no-op on SQLite."""
43
- if engine.dialect.name == "sqlite":
44
- return
45
- async with engine.begin() as conn:
46
- await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
47
- await conn.execute(text("CREATE SCHEMA public"))
48
- # The drop took the installed extensions with it; modules' models need them.
49
- await conn.run_sync(_create_declared_extensions)
50
-
51
-
52
- def _create_declared_extensions(sync_conn: Any) -> None:
53
- from simple_module_core.discovery import discover_modules
54
- from simple_module_db.extensions import ensure_db_extensions
55
-
56
- ensure_db_extensions(sync_conn, discover_modules())
57
-
58
-
59
- # The test the schema was last emptied for. On SQLite every engine is its own
60
- # in-memory database; on Postgres the ``app`` and ``db_session`` fixtures share
61
- # one, so a second reset inside the same test would wipe the first fixture's
62
- # rows (the seeded admin, say) from under it.
63
- _current_test: object | None = None
64
- _reset_for: object | None = None
65
-
66
-
67
- def begin_test(token: object) -> None:
68
- """Mark the start of a test; the next ``reset_schema_once`` resets."""
69
- global _current_test
70
- _current_test = token
71
-
72
-
73
- async def reset_schema_once(engine: Any) -> None:
74
- """``reset_schema`` at most once per test (see ``begin_test``)."""
75
- global _reset_for
76
- if _current_test is not None and _reset_for is _current_test:
77
- return
78
- await reset_schema(engine)
79
- _reset_for = _current_test
80
-
81
-
82
- __all__ = [
83
- "SQLITE_MEMORY",
84
- "TEST_DATABASE_ENV",
85
- "begin_test",
86
- "database_is_postgres",
87
- "database_url_for_tests",
88
- "init_db_kwargs",
89
- "is_sqlite",
90
- "reset_schema",
91
- "reset_schema_once",
92
- ]