simple-module-test 0.0.33__tar.gz → 0.0.35__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.33 → simple_module_test-0.0.35}/PKG-INFO +4 -4
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/pyproject.toml +4 -4
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/simple_module_test/__init__.py +2 -0
- simple_module_test-0.0.35/simple_module_test/_schema.py +80 -0
- simple_module_test-0.0.35/simple_module_test/database.py +78 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/simple_module_test/fixtures.py +43 -69
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/simple_module_test/plugin.py +13 -0
- simple_module_test-0.0.35/simple_module_test/redis_server.py +145 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/.gitignore +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/LICENSE +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/README.md +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/simple_module_test/app_factory.py +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/simple_module_test/fake_events.py +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/simple_module_test/routes.py +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/simple_module_test/session_cookie.py +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/tests/test_app_factory.py +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/tests/test_fake_events.py +0 -0
- {simple_module_test-0.0.33 → simple_module_test-0.0.35}/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.35
|
|
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
|
|
@@ -25,9 +25,9 @@ Requires-Dist: fastapi>=0.115
|
|
|
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.35
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.35
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.35
|
|
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.35"
|
|
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"
|
|
@@ -25,9 +25,9 @@ dependencies = [
|
|
|
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.35",
|
|
29
|
+
"simple_module_db==0.0.35",
|
|
30
|
+
"simple_module_hosting==0.0.35",
|
|
31
31
|
"sqlalchemy>=2.0",
|
|
32
32
|
]
|
|
33
33
|
|
|
@@ -15,6 +15,8 @@ Primary exports:
|
|
|
15
15
|
The pytest plugin auto-registers these fixtures (no imports needed):
|
|
16
16
|
``fake_event_bus``, ``build_test_app``, ``settings``, ``db_state``,
|
|
17
17
|
``engine``, ``db_session``, ``app``, ``client``, and ``authenticated_client``.
|
|
18
|
+
``redis_server`` starts a throwaway ``redis-server`` for the tests that need a
|
|
19
|
+
real broker (cross-worker cache invalidation) and skips when the binary is absent.
|
|
18
20
|
See ``simple_module_test.plugin`` (and ``simple_module_test.fixtures``) for
|
|
19
21
|
their definitions. ``authenticated_client`` additionally requires the ``users``
|
|
20
22
|
module to be installed — it seeds an admin via ``users.bootstrap``.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Schema construction for the plugin's app fixtures.
|
|
2
|
+
|
|
3
|
+
Split out of :mod:`simple_module_test.fixtures` so that module declares
|
|
4
|
+
fixtures and this one holds the machinery underneath them: importing every
|
|
5
|
+
installed module's models, resolving the migration heads, creating the tables
|
|
6
|
+
and stamping ``alembic_version`` so the boot-time migration check passes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import importlib
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
from simple_module_core.discovery import discover_modules
|
|
16
|
+
from simple_module_db.base import all_module_bases
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@lru_cache(maxsize=1)
|
|
20
|
+
def _ensure_models_imported() -> list:
|
|
21
|
+
"""Import all module models so all_module_bases is populated (cached)."""
|
|
22
|
+
for mod in discover_modules():
|
|
23
|
+
pkg = type(mod).__module__.split(".")[0]
|
|
24
|
+
with contextlib.suppress(ModuleNotFoundError):
|
|
25
|
+
importlib.import_module(f"{pkg}.models")
|
|
26
|
+
return list(all_module_bases)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@lru_cache(maxsize=1)
|
|
30
|
+
def _alembic_heads() -> tuple[str, ...]:
|
|
31
|
+
"""Cached head revisions — cannot change within a pytest run.
|
|
32
|
+
|
|
33
|
+
Plural: each module's first migration sets its own ``branch_labels``, so
|
|
34
|
+
the history has one head per module and a real upgraded database carries
|
|
35
|
+
an ``alembic_version`` row for each. Stamping only one leaves the rest
|
|
36
|
+
looking un-applied, which now reads as a behind-head schema and puts every
|
|
37
|
+
test app behind the setup gate.
|
|
38
|
+
"""
|
|
39
|
+
from simple_module_hosting.migrations import resolve_head_revisions
|
|
40
|
+
|
|
41
|
+
return resolve_head_revisions()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _create_all_tables(engine) -> None:
|
|
45
|
+
"""Create all module tables in a single connection.
|
|
46
|
+
|
|
47
|
+
Also stamps the alembic_version table at heads so the app's startup
|
|
48
|
+
migration check (``check_migrations``) treats the test DB as current.
|
|
49
|
+
Without the stamp the check would raise because ``create_all`` doesn't
|
|
50
|
+
touch alembic_version.
|
|
51
|
+
"""
|
|
52
|
+
from sqlalchemy import text
|
|
53
|
+
|
|
54
|
+
from simple_module_test.database import reset_schema_once
|
|
55
|
+
|
|
56
|
+
bases = _ensure_models_imported()
|
|
57
|
+
heads = _alembic_heads()
|
|
58
|
+
await reset_schema_once(engine) # Postgres: start empty; SQLite memory already is
|
|
59
|
+
|
|
60
|
+
async with engine.begin() as conn:
|
|
61
|
+
|
|
62
|
+
def _sync_create_all(sync_conn):
|
|
63
|
+
for base in bases:
|
|
64
|
+
base.metadata.create_all(sync_conn)
|
|
65
|
+
|
|
66
|
+
await conn.run_sync(_sync_create_all)
|
|
67
|
+
|
|
68
|
+
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
|
+
await conn.execute(text("DELETE FROM alembic_version"))
|
|
76
|
+
for head in heads:
|
|
77
|
+
await conn.execute(
|
|
78
|
+
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
|
|
79
|
+
{"v": head},
|
|
80
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
]
|
|
@@ -20,20 +20,18 @@ which soft-imports ``background_tasks``).
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
-
import contextlib
|
|
24
|
-
import importlib
|
|
25
23
|
import os
|
|
26
24
|
from collections.abc import AsyncGenerator, Iterator
|
|
27
|
-
from functools import lru_cache
|
|
28
25
|
|
|
29
26
|
import httpx
|
|
30
27
|
import pytest
|
|
31
|
-
from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER
|
|
32
|
-
from simple_module_db.base import all_module_bases
|
|
28
|
+
from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER
|
|
33
29
|
from simple_module_db.session import DatabaseState, init_db
|
|
34
30
|
from simple_module_hosting.settings import Settings
|
|
35
31
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
36
32
|
|
|
33
|
+
from simple_module_test._schema import _create_all_tables
|
|
34
|
+
from simple_module_test.database import begin_test, database_url_for_tests, init_db_kwargs
|
|
37
35
|
from simple_module_test.session_cookie import forge_session_cookie
|
|
38
36
|
|
|
39
37
|
_AUTH_PROVIDER_ENV = "SM_AUTH_PROVIDER"
|
|
@@ -66,6 +64,16 @@ def pinned_auth_provider() -> Iterator[str]:
|
|
|
66
64
|
os.environ[_AUTH_PROVIDER_ENV] = previous
|
|
67
65
|
|
|
68
66
|
|
|
67
|
+
@pytest.fixture(autouse=True)
|
|
68
|
+
def fresh_test_database(request: pytest.FixtureRequest) -> None:
|
|
69
|
+
"""Let the first fixture of each test empty the Postgres schema, and no later one.
|
|
70
|
+
|
|
71
|
+
A no-op on the default in-memory SQLite, where every engine is already
|
|
72
|
+
its own empty database (see ``simple_module_test.database``).
|
|
73
|
+
"""
|
|
74
|
+
begin_test(request.node)
|
|
75
|
+
|
|
76
|
+
|
|
69
77
|
@pytest.fixture
|
|
70
78
|
def settings() -> Settings:
|
|
71
79
|
"""Settings configured for testing with in-memory SQLite.
|
|
@@ -81,7 +89,7 @@ def settings() -> Settings:
|
|
|
81
89
|
that expects ``/users/login``.
|
|
82
90
|
"""
|
|
83
91
|
return Settings(
|
|
84
|
-
database_url=
|
|
92
|
+
database_url=database_url_for_tests(),
|
|
85
93
|
environment="testing",
|
|
86
94
|
secret_key="test-secret-key",
|
|
87
95
|
multi_tenant=True,
|
|
@@ -95,7 +103,8 @@ async def db_state() -> AsyncGenerator[DatabaseState, None]:
|
|
|
95
103
|
"""Create a fresh in-memory DatabaseState with listeners registered."""
|
|
96
104
|
from simple_module_db.listeners import register_listeners
|
|
97
105
|
|
|
98
|
-
|
|
106
|
+
url = database_url_for_tests()
|
|
107
|
+
state = init_db(url, **init_db_kwargs(url))
|
|
99
108
|
register_listeners(state)
|
|
100
109
|
yield state
|
|
101
110
|
await state.engine.dispose()
|
|
@@ -107,67 +116,6 @@ async def engine(db_state: DatabaseState) -> AsyncEngine:
|
|
|
107
116
|
return db_state.engine
|
|
108
117
|
|
|
109
118
|
|
|
110
|
-
@lru_cache(maxsize=1)
|
|
111
|
-
def _ensure_models_imported() -> list:
|
|
112
|
-
"""Import all module models so all_module_bases is populated (cached)."""
|
|
113
|
-
for mod in discover_modules():
|
|
114
|
-
pkg = type(mod).__module__.split(".")[0]
|
|
115
|
-
with contextlib.suppress(ModuleNotFoundError):
|
|
116
|
-
importlib.import_module(f"{pkg}.models")
|
|
117
|
-
return list(all_module_bases)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
@lru_cache(maxsize=1)
|
|
121
|
-
def _alembic_heads() -> tuple[str, ...]:
|
|
122
|
-
"""Cached head revisions — cannot change within a pytest run.
|
|
123
|
-
|
|
124
|
-
Plural: each module's first migration sets its own ``branch_labels``, so
|
|
125
|
-
the history has one head per module and a real upgraded database carries
|
|
126
|
-
an ``alembic_version`` row for each. Stamping only one leaves the rest
|
|
127
|
-
looking un-applied, which now reads as a behind-head schema and puts every
|
|
128
|
-
test app behind the setup gate.
|
|
129
|
-
"""
|
|
130
|
-
from simple_module_hosting.migrations import resolve_head_revisions
|
|
131
|
-
|
|
132
|
-
return resolve_head_revisions()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
async def _create_all_tables(engine) -> None:
|
|
136
|
-
"""Create all module tables in a single connection.
|
|
137
|
-
|
|
138
|
-
Also stamps the alembic_version table at heads so the app's startup
|
|
139
|
-
migration check (``check_migrations``) treats the test DB as current.
|
|
140
|
-
Without the stamp the check would raise because ``create_all`` doesn't
|
|
141
|
-
touch alembic_version.
|
|
142
|
-
"""
|
|
143
|
-
from sqlalchemy import text
|
|
144
|
-
|
|
145
|
-
bases = _ensure_models_imported()
|
|
146
|
-
heads = _alembic_heads()
|
|
147
|
-
|
|
148
|
-
async with engine.begin() as conn:
|
|
149
|
-
|
|
150
|
-
def _sync_create_all(sync_conn):
|
|
151
|
-
for base in bases:
|
|
152
|
-
base.metadata.create_all(sync_conn)
|
|
153
|
-
|
|
154
|
-
await conn.run_sync(_sync_create_all)
|
|
155
|
-
|
|
156
|
-
if heads:
|
|
157
|
-
await conn.execute(
|
|
158
|
-
text(
|
|
159
|
-
"CREATE TABLE IF NOT EXISTS alembic_version "
|
|
160
|
-
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
|
161
|
-
)
|
|
162
|
-
)
|
|
163
|
-
await conn.execute(text("DELETE FROM alembic_version"))
|
|
164
|
-
for head in heads:
|
|
165
|
-
await conn.execute(
|
|
166
|
-
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
|
|
167
|
-
{"v": head},
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
|
|
171
119
|
@pytest.fixture
|
|
172
120
|
async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]:
|
|
173
121
|
"""Yield an async session backed by in-memory SQLite."""
|
|
@@ -236,12 +184,38 @@ async def app(settings: Settings):
|
|
|
236
184
|
yield application
|
|
237
185
|
|
|
238
186
|
|
|
187
|
+
def _disable_env_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
188
|
+
"""Stop ``bootstrap_admin_from_env`` seeding the app being built.
|
|
189
|
+
|
|
190
|
+
``UsersModule.on_startup`` creates an administrator from
|
|
191
|
+
``SM_USERS_BOOTSTRAP_*``, read from the environment *and* from a ``.env`` on
|
|
192
|
+
disk. A developer who followed ``.env.example`` has those set, so every app
|
|
193
|
+
this plugin builds gets an admin whether the fixture asked for one or not.
|
|
194
|
+
For ``setup_pending_app`` that is not a nuisance but the opposite of its
|
|
195
|
+
contract: the setup gate releases the moment an administrator exists, so the
|
|
196
|
+
wizard routes 404 and every test using it fails.
|
|
197
|
+
|
|
198
|
+
They fail *locally only* — CI has no ``.env`` — which is the worst shape for
|
|
199
|
+
a fixture to be wrong in, and left the failure looking like test-ordering
|
|
200
|
+
noise rather than a fixture that does not do what it says.
|
|
201
|
+
"""
|
|
202
|
+
for key in list(os.environ):
|
|
203
|
+
if key.startswith("SM_USERS_BOOTSTRAP_"):
|
|
204
|
+
monkeypatch.delenv(key, raising=False)
|
|
205
|
+
try:
|
|
206
|
+
from users import bootstrap as bootstrap_module
|
|
207
|
+
except ImportError:
|
|
208
|
+
return # No local-accounts provider installed; nothing bootstraps.
|
|
209
|
+
monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
|
|
210
|
+
|
|
211
|
+
|
|
239
212
|
@pytest.fixture
|
|
240
|
-
async def setup_pending_app(settings: Settings):
|
|
213
|
+
async def setup_pending_app(settings: Settings, monkeypatch: pytest.MonkeyPatch):
|
|
241
214
|
"""An app with no administrator, so the first-run setup gate is engaged.
|
|
242
215
|
|
|
243
216
|
The counterpart to ``app``: use this to assert on setup-mode behaviour.
|
|
244
217
|
"""
|
|
218
|
+
_disable_env_bootstrap(monkeypatch)
|
|
245
219
|
async for application in _build_app(settings, seed_admin=False):
|
|
246
220
|
yield application
|
|
247
221
|
|
|
@@ -9,6 +9,7 @@ test suites — treat the fixture surface like a public API.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import contextlib
|
|
12
|
+
import os
|
|
12
13
|
|
|
13
14
|
import pytest
|
|
14
15
|
|
|
@@ -27,10 +28,12 @@ from simple_module_test.fixtures import ( # noqa: F401
|
|
|
27
28
|
db_session,
|
|
28
29
|
db_state,
|
|
29
30
|
engine,
|
|
31
|
+
fresh_test_database,
|
|
30
32
|
pinned_auth_provider,
|
|
31
33
|
settings,
|
|
32
34
|
setup_pending_app,
|
|
33
35
|
)
|
|
36
|
+
from simple_module_test.redis_server import redis_server # noqa: F401
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
def _bootstrap_eager_celery() -> None:
|
|
@@ -40,7 +43,17 @@ def _bootstrap_eager_celery() -> None:
|
|
|
40
43
|
so the host's ``build_celery`` call never runs and ``task.delay()``
|
|
41
44
|
falls through to the broker. Skipped silently when ``background_tasks``
|
|
42
45
|
isn't installed.
|
|
46
|
+
|
|
47
|
+
Also switches off the cross-worker invalidation transport. ``on_startup``
|
|
48
|
+
would otherwise open a Redis pub/sub listener per app fixture — against a
|
|
49
|
+
broker no test environment runs — leaving a reconnect loop in the
|
|
50
|
+
background of every suite. ``setdefault``, so a test that wants the transport
|
|
51
|
+
can set the variable and get it — though the tests that exercise the transport
|
|
52
|
+
build ``RedisInvalidationTransport`` directly instead, against either a fake
|
|
53
|
+
client (``test_bg_invalidation.py``) or a throwaway ``redis-server`` (the
|
|
54
|
+
``redis_server`` fixture, used by ``test_bg_invalidation_redis.py``).
|
|
43
55
|
"""
|
|
56
|
+
os.environ.setdefault("SM_BG_TASKS_BROADCAST_INVALIDATIONS", "false")
|
|
44
57
|
with contextlib.suppress(ImportError):
|
|
45
58
|
from background_tasks.celery_app import build_celery
|
|
46
59
|
from background_tasks.settings import BackgroundTasksSettings
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""A throwaway ``redis-server`` for tests that need a real broker.
|
|
2
|
+
|
|
3
|
+
The cross-worker invalidation transport is the one piece of this framework whose
|
|
4
|
+
whole job happens *between* processes, and a fake client cannot show that it
|
|
5
|
+
works — it can only show that this repo's own retry and teardown logic behaves.
|
|
6
|
+
So the tests that matter start a real server.
|
|
7
|
+
|
|
8
|
+
Deliberately a subprocess rather than a Docker service or the shared
|
|
9
|
+
``dev-services`` stack: a unit-test job that runs ``make install-py`` and nothing
|
|
10
|
+
else must be able to run these, and a developer must not have to bring anything
|
|
11
|
+
up first. The server is ephemeral — a free port, no persistence
|
|
12
|
+
(``--save '' --appendonly no``), killed on teardown — so it cannot collide with
|
|
13
|
+
the real ``dev-services`` Redis on 6379 or leave state behind.
|
|
14
|
+
|
|
15
|
+
``SM_TEST_REDIS_URL`` overrides all of this and is used as-is, which is how CI
|
|
16
|
+
supplies a Redis **service container** instead of a binary: GitHub's
|
|
17
|
+
``ubuntu-latest`` image does not ship ``redis-server``, contrary to what this
|
|
18
|
+
docstring first claimed — the ``CI`` guard below caught that on the first run,
|
|
19
|
+
which is the whole reason it exists.
|
|
20
|
+
|
|
21
|
+
Absent both the variable and the binary the fixture skips — except under ``CI``,
|
|
22
|
+
where it **fails**.
|
|
23
|
+
A skip is right on a developer's machine and wrong on a build: the whole point of
|
|
24
|
+
these tests is to cover what the fake client cannot, and a runner image that
|
|
25
|
+
stopped shipping ``redis-server`` would silently shed every one of them while the
|
|
26
|
+
build stayed green. That is the same "passes while testing nothing" failure the
|
|
27
|
+
tests themselves exist to rule out, so it gets an explicit guard rather than a
|
|
28
|
+
sentence in a plan.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import contextlib
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
import socket
|
|
37
|
+
import subprocess
|
|
38
|
+
import time
|
|
39
|
+
from collections.abc import Iterator
|
|
40
|
+
|
|
41
|
+
import pytest
|
|
42
|
+
|
|
43
|
+
__all__ = ["redis_server"]
|
|
44
|
+
|
|
45
|
+
_STARTUP_TIMEOUT_SECONDS = 10.0
|
|
46
|
+
_POLL_INTERVAL_SECONDS = 0.05
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _free_port() -> int:
|
|
50
|
+
"""A port nothing is listening on, as of a moment ago.
|
|
51
|
+
|
|
52
|
+
Inherently racy — the port could be taken between the probe and the bind —
|
|
53
|
+
but the alternative is a fixed port, which collides with a developer's own
|
|
54
|
+
Redis and with a parallel test run. ``redis-server`` fails loudly on a taken
|
|
55
|
+
port, and :func:`_wait_until_ready` turns that into a clear timeout rather
|
|
56
|
+
than a hang.
|
|
57
|
+
"""
|
|
58
|
+
with socket.socket() as sock:
|
|
59
|
+
sock.bind(("127.0.0.1", 0))
|
|
60
|
+
return int(sock.getsockname()[1])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _wait_until_ready(process: subprocess.Popen, port: int) -> None:
|
|
64
|
+
"""Block until the server accepts a connection, or fail with its output.
|
|
65
|
+
|
|
66
|
+
Polls a TCP connect rather than shelling out to ``redis-cli``: one less
|
|
67
|
+
binary to require, and the thing under test is whether a socket opens.
|
|
68
|
+
"""
|
|
69
|
+
deadline = time.monotonic() + _STARTUP_TIMEOUT_SECONDS
|
|
70
|
+
while time.monotonic() < deadline:
|
|
71
|
+
if process.poll() is not None:
|
|
72
|
+
output = (process.stdout.read() if process.stdout else "") or "(no output)"
|
|
73
|
+
raise RuntimeError(f"redis-server exited during startup: {output}")
|
|
74
|
+
with contextlib.suppress(OSError), socket.create_connection(("127.0.0.1", port), 0.2):
|
|
75
|
+
return
|
|
76
|
+
time.sleep(_POLL_INTERVAL_SECONDS)
|
|
77
|
+
process.kill()
|
|
78
|
+
raise RuntimeError(f"redis-server did not accept connections on {port} in time")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture(scope="session")
|
|
82
|
+
def redis_server() -> Iterator[str]:
|
|
83
|
+
"""A URL for a usable Redis. Session-scoped — a local start costs ~50ms.
|
|
84
|
+
|
|
85
|
+
Session scope means tests share one server, so anything that would leave a
|
|
86
|
+
subscription or a key behind must clean up after itself. Pub/sub carries no
|
|
87
|
+
state between messages, which is why sharing is safe for the invalidation
|
|
88
|
+
tests; a test that wrote keys would want its own database index.
|
|
89
|
+
|
|
90
|
+
With ``SM_TEST_REDIS_URL`` set the server is somebody else's — a CI service
|
|
91
|
+
container — so it is neither private nor guaranteed empty, and this fixture
|
|
92
|
+
neither starts nor stops it.
|
|
93
|
+
"""
|
|
94
|
+
external = os.environ.get("SM_TEST_REDIS_URL")
|
|
95
|
+
if external:
|
|
96
|
+
yield external
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
binary = shutil.which("redis-server")
|
|
100
|
+
if binary is None:
|
|
101
|
+
message = (
|
|
102
|
+
"no redis-server binary and no SM_TEST_REDIS_URL, so the real-transport "
|
|
103
|
+
"and cross-process tests cannot run"
|
|
104
|
+
)
|
|
105
|
+
if os.environ.get("CI"):
|
|
106
|
+
pytest.fail(
|
|
107
|
+
f"{message}. On CI this is a failure, not a skip: silently dropping "
|
|
108
|
+
"these tests would leave the cross-process claim untested while the "
|
|
109
|
+
"build stayed green. Give the job a redis service and point "
|
|
110
|
+
"SM_TEST_REDIS_URL at it, or install redis-server on the runner."
|
|
111
|
+
)
|
|
112
|
+
pytest.skip(message)
|
|
113
|
+
|
|
114
|
+
port = _free_port()
|
|
115
|
+
process = subprocess.Popen(
|
|
116
|
+
[
|
|
117
|
+
binary,
|
|
118
|
+
"--port",
|
|
119
|
+
str(port),
|
|
120
|
+
"--bind",
|
|
121
|
+
"127.0.0.1",
|
|
122
|
+
# No persistence: nothing to write, nothing to load, no dump.rdb
|
|
123
|
+
# dropped into whatever directory pytest happened to run from.
|
|
124
|
+
"--save",
|
|
125
|
+
"",
|
|
126
|
+
"--appendonly",
|
|
127
|
+
"no",
|
|
128
|
+
"--databases",
|
|
129
|
+
"1",
|
|
130
|
+
],
|
|
131
|
+
stdout=subprocess.PIPE,
|
|
132
|
+
stderr=subprocess.STDOUT,
|
|
133
|
+
text=True,
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
_wait_until_ready(process, port)
|
|
137
|
+
yield f"redis://127.0.0.1:{port}/0"
|
|
138
|
+
finally:
|
|
139
|
+
process.terminate()
|
|
140
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
141
|
+
process.wait(timeout=5)
|
|
142
|
+
if process.poll() is None:
|
|
143
|
+
process.kill()
|
|
144
|
+
if process.stdout is not None:
|
|
145
|
+
process.stdout.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_test-0.0.33 → simple_module_test-0.0.35}/simple_module_test/session_cookie.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|