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.
Files changed (26) hide show
  1. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/.gitignore +2 -2
  2. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/PKG-INFO +5 -5
  3. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/pyproject.toml +5 -5
  4. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/__init__.py +3 -0
  5. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/_schema.py +23 -13
  6. simple_module_test-0.0.38/simple_module_test/database.py +199 -0
  7. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/fake_events.py +1 -1
  8. simple_module_test-0.0.38/simple_module_test/mail.py +49 -0
  9. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/plugin.py +78 -0
  10. simple_module_test-0.0.38/simple_module_test/waiting.py +83 -0
  11. simple_module_test-0.0.38/tests/test_schema_reuse_pg.py +75 -0
  12. simple_module_test-0.0.38/tests/test_waiting_helpers.py +88 -0
  13. simple_module_test-0.0.38/tests/test_worker_database.py +51 -0
  14. simple_module_test-0.0.36/simple_module_test/database.py +0 -78
  15. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/LICENSE +0 -0
  16. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/README.md +0 -0
  17. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/app_factory.py +0 -0
  18. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/fixtures.py +0 -0
  19. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/redis_server.py +0 -0
  20. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/routes.py +0 -0
  21. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/session_cookie.py +0 -0
  22. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/setup_wizard.py +0 -0
  23. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/simple_module_test/tenant_client.py +0 -0
  24. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/tests/test_app_factory.py +0 -0
  25. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/tests/test_fake_events.py +0 -0
  26. {simple_module_test-0.0.36 → simple_module_test-0.0.38}/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/
@@ -64,3 +62,5 @@ host/client_app/.playwright-cli/*
64
62
  .superpowers/
65
63
  .qa/
66
64
  .verify/
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.36
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>=0.115
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.36
29
- Requires-Dist: simple-module-db==0.0.36
30
- Requires-Dist: simple-module-hosting==0.0.36
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.36"
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.115",
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.36",
29
- "simple_module_db==0.0.36",
30
- "simple_module_hosting==0.0.36",
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 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
 
@@ -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
- ]