swadhinbiswas π release: 2.0.8 β write-path fix (ADR-080), fork-safe GIL pool (ADR-081), HTTP/3 (ADR-082), Security DI fix, docs honesty pass Β· 8/6/2026
731fffe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
"""JustAPI testing utilities β async fixtures, DB helpers, and assertions.
.. warning::
Test functions **must not** have the same name as imported helpers.
E.g. ``from justapi.testing import test_db`` then ``def test_db():``
will cause a name collision β rename your test to ``test_my_db:`` instead.
Usage:
import pytest
import pytest_asyncio
from justapi import JustAPIApp
from justapi.testing import AsyncTestClient, assert_ok, assert_json
@pytest_asyncio.fixture
async def app():
app = JustAPIApp()
app.get("/ping", lambda r: {"pong": True})
return app
@pytest_asyncio.fixture
async def client(app):
async with AsyncTestClient(app) as c:
yield c
@pytest.mark.asyncio
async def test_ping(client):
resp = await client.get("/ping")
assert_ok(resp)
assert_json(resp, {"pong": True})
# With database:
@pytest_asyncio.fixture
async def db_client(app):
async with AsyncTestClient(app, database="sqlite::memory:") as c:
yield c
"""
import asyncio
import inspect
import json
import os
import subprocess
import sys
import tempfile
from functools import partial
from typing import Any, Awaitable, Callable, Optional
class AsyncTestClient:
"""Async wrapper around *JustAPITestClient* for use in ``pytest-asyncio`` fixtures.
Use as an async context manager:
async with AsyncTestClient(app) as client:
resp = await client.get("/hello")
To test database-backed routes, pass the *database* URL:
async with AsyncTestClient(app, database="sqlite::memory:") as client:
resp = await client.post("/users", b'{"name":"Alice"}')
"""
def __init__(self, app: "Any", database: Optional[str] = None) -> None:
self._app = app
self._database = database
self._client: Optional["Any"] = None
self._setup_hooks: list[Callable[["AsyncTestClient"], Awaitable[None]]] = []
self._teardown_hooks: list[Callable[["AsyncTestClient"], Awaitable[None]]] = []
async def _build_client(self) -> None:
from justapi import JustAPITestClient
inner_app = getattr(self._app, "_inner", self._app)
self._client = JustAPITestClient(inner_app, database=self._database)
async def _destroy_client(self) -> None:
self._client = None
async def __aenter__(self) -> "AsyncTestClient":
await self._build_client()
for hook in self._setup_hooks:
res = hook(self)
if res is not None:
await res
return self
async def __aexit__(self, *exc_info: Any) -> None:
for hook in reversed(self._teardown_hooks):
res = hook(self)
if res is not None:
await res
await self._destroy_client()
def on_setup(self, hook: Callable[["AsyncTestClient"], Awaitable[None]]) -> "AsyncTestClient":
self._setup_hooks.append(hook)
return self
def on_teardown(self, hook: Callable[["AsyncTestClient"], Awaitable[None]]) -> "AsyncTestClient":
self._teardown_hooks.append(hook)
return self
async def _run(self, method: str, path: str, body: bytes = b"") -> dict:
client = self._client
if client is None:
raise RuntimeError("AsyncTestClient not initialized")
loop = asyncio.get_running_loop()
if method in ("GET", "DELETE"):
fn = partial(getattr(client, method.lower()), path)
else:
fn = partial(getattr(client, method.lower()), path, body)
return await loop.run_in_executor(None, fn)
async def get(self, path: str) -> dict:
return await self._run("GET", path)
async def post(self, path: str, body: bytes = b"") -> dict:
return await self._run("POST", path, body)
async def put(self, path: str, body: bytes = b"") -> dict:
return await self._run("PUT", path, body)
async def patch(self, path: str, body: bytes = b"") -> dict:
return await self._run("PATCH", path, body)
async def delete(self, path: str) -> dict:
return await self._run("DELETE", path)
async def query(self, path: str, body: bytes = b"") -> dict:
client = self._client
if client is None:
raise RuntimeError("AsyncTestClient not initialized")
loop = asyncio.get_running_loop()
# RFC 10008 requires a Content-Type on QUERY requests.
fn = partial(client.query_with, path, body, [("Content-Type", "application/json")])
return await loop.run_in_executor(None, fn)
# ---------------------------------------------------------------------------
# Database test helpers
# ---------------------------------------------------------------------------
class ManagedDb:
"""Managed test database with automatic cleanup.
Use as an async context manager:
async with ManagedDb(url="sqlite://:memory:") as db:
app.set_database(db.url)
# run routes against in-memory SQLite
"""
def __init__(self, url: str = "sqlite://:memory:", migrations_dir: Optional[str] = None):
self._url = url
self._migrations_dir = migrations_dir
self._cleanup_paths: list[str] = []
@property
def url(self) -> str:
return self._url
async def run_migrations(self, directory: Optional[str] = None) -> None:
"""Run SQL migrations against the test database using sqlx CLI."""
migrations = directory or self._migrations_dir
if not migrations:
return
if not os.path.isdir(migrations):
raise FileNotFoundError(f"Migrations directory not found: {migrations}")
loop = asyncio.get_running_loop()
def _run():
result = subprocess.run(
[sys.executable, "-m", "sqlx", "migrate", "run",
"--database-url", self._url, "--source", migrations],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
raise RuntimeError(
f"Migration failed: {result.stderr.strip() or result.stdout.strip()}"
)
await loop.run_in_executor(None, _run)
async def run_sql(self, sql: str) -> None:
"""Execute raw SQL against the test database.
For file-based databases (``sqlite:///path/to/db.db``), runs SQL
against the same file. For in-memory databases (``sqlite://:memory:``),
this opens a *separate* in-memory database β use only for schema
metadata that doesn't need to persist across connections.
"""
import sqlite3
path = _sqlite_path(self._url)
loop = asyncio.get_running_loop()
def _run():
conn = sqlite3.connect(path)
try:
conn.executescript(sql)
conn.commit()
finally:
conn.close()
await loop.run_in_executor(None, _run)
async def __aenter__(self) -> "ManagedDb":
return self
async def __aexit__(self, *exc_info: Any) -> None:
for path in self._cleanup_paths:
if os.path.exists(path):
os.unlink(path)
def test_db(url: str = "sqlite://:memory:", migrations_dir: Optional[str] = None) -> ManagedDb:
"""Create a *ManagedDb* instance for use in pytest fixtures.
Usage:
@pytest_asyncio.fixture
async def db():
async with test_db() as db:
await db.run_sql("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
yield db
"""
return ManagedDb(url=url, migrations_dir=migrations_dir)
# ---------------------------------------------------------------------------
# DB fixture helper β configure app with test database and return AsyncTestClient
# ---------------------------------------------------------------------------
async def db_client(
app: "Any",
url: str = "sqlite://:memory:",
*,
seed_sql: Optional[str] = None,
) -> AsyncTestClient:
"""Create an *AsyncTestClient* configured with a test database.
Usage:
@pytest_asyncio.fixture
async def client(app):
async with await db_client(app, seed_sql="CREATE TABLE ...") as c:
yield c
"""
if seed_sql:
import sqlite3
db_path = _sqlite_path(url)
conn = sqlite3.connect(db_path)
try:
conn.executescript(seed_sql)
conn.commit()
finally:
conn.close()
c = AsyncTestClient(app, database=url)
await c._build_client()
return c
# ---------------------------------------------------------------------------
# Snapshot testing
# ---------------------------------------------------------------------------
class Snapshot:
"""Response body snapshot assertions.
Snapshots are stored as JSON files under ``__snapshots__/`` alongside
the test file. The first run creates the snapshot; subsequent runs
compare the response against it.
Usage:
from justapi.testing import Snapshot
snapshot = Snapshot()
async def test_get_users(client):
resp = await client.get("/users")
snapshot.assert_response(resp, "users_list")
To update all snapshots to match new output::
SNAPSHOT_UPDATE=1 python -m pytest ...
"""
def __init__(self, snap_dir: Optional[str] = None):
self._snap_dir = snap_dir
self._update = os.environ.get("SNAPSHOT_UPDATE", "").lower() in (
"1",
"true",
"yes",
)
# -- Public API -------------------------------------------------------
def assert_response(self, resp: dict, name: Optional[str] = None) -> None:
"""Assert that a response dict (status + headers + body) matches its
stored snapshot."""
path = self._resolve_path(name)
current = _serialize_response(resp)
self._assert_match(path, current)
def assert_match(self, value: Any, name: Optional[str] = None) -> None:
"""Assert that an arbitrary JSON-serialisable value matches its
stored snapshot."""
path = self._resolve_path(name)
current = json.dumps(value, indent=2, default=str, ensure_ascii=False)
self._assert_match(path, current)
def assert_body(self, resp: dict, name: Optional[str] = None) -> None:
"""Assert that the raw response body (as bytes) matches its stored
snapshot (stored as base64)."""
path = self._resolve_path(name)
import base64
current = base64.b64encode(bytes(resp["body"])).decode("ascii")
self._assert_match(path, current)
# -- Internals --------------------------------------------------------
def _resolve_path(self, name: Optional[str] = None) -> str:
# Walk up the stack to find the first frame outside testing.py
frame = inspect.currentframe()
while frame:
mod_file = frame.f_globals.get("__file__", "")
if not mod_file.endswith("testing.py"):
break
frame = frame.f_back
if name is not None:
snap_name = name
else:
snap_name = frame.f_code.co_name if frame else "unnamed"
if self._snap_dir:
base = self._snap_dir
else:
caller_file = frame.f_globals.get("__file__", ".") if frame else "."
base = os.path.join(os.path.dirname(caller_file), "__snapshots__")
os.makedirs(base, exist_ok=True)
return os.path.join(base, f"{snap_name}.snap")
def _assert_match(self, path: str, current: str) -> None:
if self._update:
self._write(path, current)
return
if not os.path.exists(path):
self._write(path, current)
return
stored = self._read(path)
if stored != current:
msg_lines = [
f"Snapshot mismatch: {path}",
"",
"--- stored",
"+++ current",
]
for diff in _diff_lines(stored, current):
msg_lines.append(diff)
msg_lines.append("")
msg_lines.append("Run with SNAPSHOT_UPDATE=1 to update.")
raise AssertionError("\n".join(msg_lines))
@staticmethod
def _write(path: str, content: str) -> None:
with open(path, "w") as f:
f.write(content)
f.write("\n")
@staticmethod
def _read(path: str) -> str:
with open(path) as f:
return f.read().rstrip("\n")
def _serialize_response(resp: dict) -> str:
import base64
body_b64 = base64.b64encode(bytes(resp.get("body", b""))).decode("ascii")
headers = {}
for k, v in resp.get("headers", {}).items():
headers[k] = v
return json.dumps(
{
"status": resp.get("status", 200),
"headers": headers,
"body_base64": body_b64,
},
indent=2,
ensure_ascii=False,
)
def _diff_lines(a: str, b: str) -> list[str]:
"""Simple line-by-line diff between two strings."""
import difflib
return list(
difflib.unified_diff(
a.splitlines(keepends=True),
b.splitlines(keepends=True),
fromfile="stored",
tofile="current",
lineterm="",
)
)
# ---------------------------------------------------------------------------
# Assertion helpers
# ---------------------------------------------------------------------------
def assert_ok(resp: dict) -> None:
assert resp["status"] == 200, (
f"Expected 200, got {resp['status']}: {bytes(resp.get('body', b'')).decode(errors='replace')}"
)
def assert_status(resp: dict, status: int) -> None:
assert resp["status"] == status, (
f"Expected {status}, got {resp['status']}: {bytes(resp.get('body', b'')).decode(errors='replace')}"
)
def assert_json(resp: dict, expected: Any) -> None:
body = json.loads(bytes(resp["body"]))
assert body == expected, f"Expected {expected!r}, got {body!r}"
def assert_header(resp: dict, name: str, expected: Optional[str] = None) -> None:
headers = {k.lower(): v for k, v in resp.get("headers", {}).items()}
if expected is not None:
value = headers.get(name.lower())
assert value == expected, f"Expected header {name}={expected!r}, got {value!r}"
else:
assert name.lower() in headers, f"Expected header {name!r} not present"
def transaction_test_db(url: str = "sqlite://:memory:") -> str:
"""Return a database URL suitable for testing (in-memory SQLite by default).
Usage:
app.set_database(transaction_test_db())
"""
return url
def _sqlite_path(url: str) -> str:
"""Extract the file path from a ``sqlite://`` URL for use with ``sqlite3.connect``."""
if url == "sqlite://:memory:" or url == "sqlite://?mode=memory":
return ":memory:"
if url.startswith("sqlite://"):
path = url[len("sqlite://"):]
# Strip query parameters
if "?" in path:
path = path.split("?")[0]
return path
return url
__all__ = [
"AsyncTestClient",
"ManagedDb",
"Snapshot",
"test_db",
"db_client",
"assert_ok",
"assert_status",
"assert_json",
"assert_header",
"transaction_test_db",
]