pgdevkit 0.16.0__tar.gz → 0.16.1__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.
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/PKG-INFO +1 -1
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/fastapi/cancel_on_disconnect.py +5 -1
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/fastapi/json_response.py +8 -2
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pyproject.toml +1 -1
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/_asgi.py +22 -1
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/_granian_app.py +5 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_cancel_on_disconnect.py +31 -2
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_pg_json_cancel.py +24 -2
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_pg_json_granian.py +12 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/uv.lock +1 -1
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.gitignore +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.python-version +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/README.md +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/docs/database-layout.md +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/fetch.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/transaction.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/fastapi/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/migrate_mssql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/models.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/stats.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/skills/pgdevkit/SKILL.md +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/conftest.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_fetch.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_fetch_statement_timeout.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_query_expr_types.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_transaction.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_areas.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_compare.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_connection.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_dialect.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_envtag.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_mssql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_mssql_live.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_pg_json_response.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_pg_json_statement_timeout.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_schemas.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_stats_cli.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_stats_mssql_live.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_schema_mssql.py +0 -0
|
@@ -74,9 +74,12 @@ class CancelOnDisconnectRoute(APIRoute):
|
|
|
74
74
|
request._receive = receiver.receive # the same Request object, so a cached body is seen by everyone
|
|
75
75
|
reader = asyncio.create_task(receiver.read())
|
|
76
76
|
|
|
77
|
+
handled = False # as in PostgresJsonResponse: once set, the cancel scope may be gone
|
|
78
|
+
|
|
77
79
|
async def cancel_when_gone(cancel_scope: asyncio.Timeout) -> None:
|
|
78
80
|
await receiver.disconnected.wait()
|
|
79
|
-
|
|
81
|
+
if not handled: # (the Event can't swallow the cancellation; this keeps reschedule() safe anyway)
|
|
82
|
+
cancel_scope.reschedule(loop.time())
|
|
80
83
|
|
|
81
84
|
try:
|
|
82
85
|
async with asyncio.timeout(None) as cancel_scope:
|
|
@@ -84,6 +87,7 @@ class CancelOnDisconnectRoute(APIRoute):
|
|
|
84
87
|
try:
|
|
85
88
|
return await handler(request)
|
|
86
89
|
finally:
|
|
90
|
+
handled = True
|
|
87
91
|
watcher.cancel()
|
|
88
92
|
except TimeoutError:
|
|
89
93
|
if not cancel_scope.expired(): # the handler's own timeout
|
|
@@ -112,11 +112,16 @@ class PostgresJsonResponse(StreamingResponse):
|
|
|
112
112
|
# Starlette only watches for http.disconnect on ASGI spec < 2.4, so do it ourselves. An asyncio timeout is
|
|
113
113
|
# the cancel scope: unlike an anyio task group, it doesn't leave Granian hanging when the body fails.
|
|
114
114
|
loop = asyncio.get_running_loop()
|
|
115
|
+
streamed = False # set before the watcher is cancelled: from then on the cancel scope may be gone
|
|
115
116
|
|
|
116
117
|
async def watch_for_disconnect(cancel_scope: asyncio.Timeout) -> None:
|
|
117
|
-
|
|
118
|
+
# Checked again after every receive(): a wrapped receive() can swallow the watcher's cancellation and
|
|
119
|
+
# return instead (BaseHTTPMiddleware's turns it into an http.disconnect once the response has been sent),
|
|
120
|
+
# and reschedule() raises once the scope has been exited.
|
|
121
|
+
while not streamed and (await receive())["type"] != "http.disconnect":
|
|
118
122
|
pass
|
|
119
|
-
|
|
123
|
+
if not streamed:
|
|
124
|
+
cancel_scope.reschedule(loop.time()) # i.e. cancel the stream now
|
|
120
125
|
|
|
121
126
|
started = False
|
|
122
127
|
|
|
@@ -131,6 +136,7 @@ class PostgresJsonResponse(StreamingResponse):
|
|
|
131
136
|
try:
|
|
132
137
|
await self.stream_response(tracking_send)
|
|
133
138
|
finally:
|
|
139
|
+
streamed = True
|
|
134
140
|
watcher.cancel()
|
|
135
141
|
except TimeoutError:
|
|
136
142
|
if not cancel_scope.expired():
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Drive an ASGI response by hand, with a client that can disconnect at will (httpx's ASGITransport can't)."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import gc
|
|
4
6
|
import time
|
|
5
|
-
from collections.abc import Awaitable, Callable
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
6
8
|
|
|
7
9
|
import psycopg
|
|
8
10
|
|
|
@@ -13,6 +15,7 @@ class FakeClient:
|
|
|
13
15
|
self.sent: list[dict] = []
|
|
14
16
|
self.fail_sends_after: int | None = None # simulate ASGI spec >= 2.4: send() raises OSError once gone
|
|
15
17
|
self.disconnect_when_send_fails = False # ... and http.disconnect arrives at that very moment
|
|
18
|
+
self.send_iterations = 0 # event loop iterations each send() takes, as a write to a real socket does
|
|
16
19
|
|
|
17
20
|
async def receive(self) -> dict:
|
|
18
21
|
await self.disconnect.wait()
|
|
@@ -24,6 +27,8 @@ class FakeClient:
|
|
|
24
27
|
self.disconnect.set()
|
|
25
28
|
raise OSError("client went away")
|
|
26
29
|
self.sent.append(message)
|
|
30
|
+
for _ in range(self.send_iterations):
|
|
31
|
+
await asyncio.sleep(0)
|
|
27
32
|
|
|
28
33
|
async def call(self, app: Callable[..., Awaitable[None]]) -> None:
|
|
29
34
|
await app({"type": "http", "asgi": {"spec_version": "2.3"}}, self.receive, self.send)
|
|
@@ -51,3 +56,19 @@ async def wait_until(condition: Callable[[], Awaitable[bool] | bool], *, timeout
|
|
|
51
56
|
return True
|
|
52
57
|
await asyncio.sleep(0.05)
|
|
53
58
|
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@contextlib.asynccontextmanager
|
|
62
|
+
async def unhandled_loop_errors() -> AsyncIterator[list[dict]]:
|
|
63
|
+
"""Collect what the event loop would log, e.g. "Task exception was never retrieved" for a failed watcher task."""
|
|
64
|
+
loop = asyncio.get_running_loop()
|
|
65
|
+
errors: list[dict] = []
|
|
66
|
+
previous_handler = loop.get_exception_handler()
|
|
67
|
+
loop.set_exception_handler(lambda _, context: errors.append(context))
|
|
68
|
+
try:
|
|
69
|
+
yield errors
|
|
70
|
+
for _ in range(5): # let leftover tasks finish, then have the failed ones reported
|
|
71
|
+
await asyncio.sleep(0)
|
|
72
|
+
gc.collect()
|
|
73
|
+
finally:
|
|
74
|
+
loop.set_exception_handler(previous_handler)
|
|
@@ -58,6 +58,11 @@ async def sleep():
|
|
|
58
58
|
return PostgresJsonResponse("select pg_sleep(30) as slept", pool=pool)
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
@app.get("/fails-at-once") # before the first byte: a 500 JSON error
|
|
62
|
+
async def fails_at_once():
|
|
63
|
+
return PostgresJsonResponse("select 1 / 0 as boom", pool=pool)
|
|
64
|
+
|
|
65
|
+
|
|
61
66
|
@app.get("/fails-midway")
|
|
62
67
|
async def fails_midway():
|
|
63
68
|
return PostgresJsonResponse(
|
|
@@ -5,6 +5,7 @@ import contextvars
|
|
|
5
5
|
import json
|
|
6
6
|
import uuid
|
|
7
7
|
from collections.abc import AsyncIterator
|
|
8
|
+
from typing import Literal
|
|
8
9
|
|
|
9
10
|
import pytest
|
|
10
11
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
|
@@ -14,8 +15,8 @@ from psycopg_pool import AsyncConnectionPool
|
|
|
14
15
|
from pydantic import BaseModel
|
|
15
16
|
|
|
16
17
|
from pgdevkit.db import fetch_all
|
|
17
|
-
from pgdevkit.fastapi import CancelOnDisconnectRoute
|
|
18
|
-
from tests._asgi import active_queries, wait_until
|
|
18
|
+
from pgdevkit.fastapi import CancelOnDisconnectRoute, cancel_on_disconnect
|
|
19
|
+
from tests._asgi import active_queries, unhandled_loop_errors, wait_until
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class Filter(BaseModel):
|
|
@@ -319,6 +320,34 @@ async def test_a_handler_that_finished_wins_over_a_disconnect_that_arrives_with_
|
|
|
319
320
|
assert (client.status, client.body) == (200, b'{"done":true}')
|
|
320
321
|
|
|
321
322
|
|
|
323
|
+
class _SwallowingEvent(asyncio.Event):
|
|
324
|
+
async def wait(self) -> Literal[True]:
|
|
325
|
+
try:
|
|
326
|
+
return await super().wait()
|
|
327
|
+
except asyncio.CancelledError: # what BaseHTTPMiddleware's receive() does to PostgresJsonResponse's watcher
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def test_a_watcher_whose_cancellation_is_swallowed_leaves_the_finished_scope_alone(
|
|
332
|
+
app: FastAPI, monkeypatch: pytest.MonkeyPatch
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Issue #59 hit PostgresJsonResponse's watcher; this one waits on our own Event, which can't swallow its
|
|
335
|
+
cancellation. If it ever does, the watcher wakes up after the handler's cancel scope has been exited."""
|
|
336
|
+
|
|
337
|
+
class Receiver(cancel_on_disconnect._Receiver):
|
|
338
|
+
def __init__(self, *args: object) -> None:
|
|
339
|
+
super().__init__(*args)
|
|
340
|
+
self.disconnected = _SwallowingEvent()
|
|
341
|
+
|
|
342
|
+
monkeypatch.setattr(cancel_on_disconnect, "_Receiver", Receiver)
|
|
343
|
+
async with unhandled_loop_errors() as errors: # "Task exception was never retrieved: RuntimeError(...)"
|
|
344
|
+
client = HttpClient(path="/ok") # (not /instant: the watcher must be waiting by the time the handler is done)
|
|
345
|
+
await client.call(app)
|
|
346
|
+
|
|
347
|
+
assert (client.status, client.body) == (200, b'[{"one":1}]')
|
|
348
|
+
assert [(e["message"], e.get("exception")) for e in errors] == []
|
|
349
|
+
|
|
350
|
+
|
|
322
351
|
async def test_context_variables_and_yield_dependencies_work_as_without_the_route(app: FastAPI) -> None:
|
|
323
352
|
client = HttpClient(path="/context")
|
|
324
353
|
await client.call(app) # reset() in another context than set() would raise ValueError: 500
|
|
@@ -13,11 +13,13 @@ import pytest
|
|
|
13
13
|
from psycopg import AsyncConnection
|
|
14
14
|
from psycopg.pq import TransactionStatus
|
|
15
15
|
from psycopg_pool import AsyncConnectionPool
|
|
16
|
-
from starlette.
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
17
|
+
from starlette.requests import ClientDisconnect, Request
|
|
18
|
+
from starlette.responses import Response
|
|
17
19
|
|
|
18
20
|
from pgdevkit.fastapi import PostgresJsonResponse, json_response
|
|
19
21
|
from pgdevkit.fastapi.json_response import _cancel_if_running
|
|
20
|
-
from tests._asgi import FakeClient, active_queries, wait_until
|
|
22
|
+
from tests._asgi import FakeClient, active_queries, unhandled_loop_errors, wait_until
|
|
21
23
|
|
|
22
24
|
SLEEP_30S = "select pg_sleep(30) as slept"
|
|
23
25
|
# padded rows: Postgres only flushes its 8 kB output buffer, so tiny rows would all arrive at the very end
|
|
@@ -156,6 +158,26 @@ async def test_a_second_cancellation_during_cleanup_does_not_skip_it(postgres_ds
|
|
|
156
158
|
assert await wait_until(lambda: query_stopped(postgres_dsn, app_name), timeout=3)
|
|
157
159
|
|
|
158
160
|
|
|
161
|
+
@pytest.mark.parametrize("query", ["select 1 / 0 as boom", "select 1 as one"])
|
|
162
|
+
async def test_a_receive_that_swallows_the_watchers_cancellation_does_not_break_it(
|
|
163
|
+
pool: AsyncConnectionPool, query: str
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Issue #59 (Granian, BaseHTTPMiddleware in front): that middleware's receive() waits in an anyio task group,
|
|
166
|
+
which it cancels once the response has been sent. The watcher's own cancellation arrives with it and is swallowed
|
|
167
|
+
there, and receive() returns an http.disconnect after the stream has finished, its cancel scope exited already."""
|
|
168
|
+
|
|
169
|
+
async def passthrough(request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
170
|
+
return await call_next(request)
|
|
171
|
+
|
|
172
|
+
async with unhandled_loop_errors() as errors: # "Task exception was never retrieved: RuntimeError(...)"
|
|
173
|
+
client = FakeClient()
|
|
174
|
+
client.send_iterations = 2 # the race needs a server whose send() takes a moment, as Granian's does
|
|
175
|
+
await client.call(BaseHTTPMiddleware(PostgresJsonResponse(query, pool=pool), dispatch=passthrough))
|
|
176
|
+
|
|
177
|
+
assert client.sent[0]["status"] == (500 if "boom" in query else 200)
|
|
178
|
+
assert [(e["message"], e.get("exception")) for e in errors] == []
|
|
179
|
+
|
|
180
|
+
|
|
159
181
|
async def test_a_finished_query_is_left_alone(pool: AsyncConnectionPool) -> None:
|
|
160
182
|
"""A cancel sent after the query finished could hit the next statement of the pooled connection."""
|
|
161
183
|
async with pool.connection() as conn:
|
|
@@ -70,6 +70,18 @@ async def test_normal_response(server: str) -> None:
|
|
|
70
70
|
assert (await client.get(f"{server}/ok")).json() == [{"i": 1}, {"i": 2}, {"i": 3}]
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
@pytest.mark.parametrize("path", ["/ok", "/fails-at-once"])
|
|
74
|
+
async def test_finished_responses_leave_no_failed_watcher_behind(server: str, server_log: Path, path: str) -> None:
|
|
75
|
+
"""Issue #59: with BaseHTTPMiddleware in front, the disconnect watcher used to wake up after almost every response
|
|
76
|
+
and fail with "Cannot change state of finished Timeout" (logged as "Task exception was never retrieved")."""
|
|
77
|
+
log_start = len(server_log.read_text())
|
|
78
|
+
async with httpx.AsyncClient() as client:
|
|
79
|
+
for _ in range(20):
|
|
80
|
+
assert (await client.get(f"{server}{path}")).status_code == (200 if path == "/ok" else 500)
|
|
81
|
+
await asyncio.sleep(0.2) # the server logs it when the failed task is collected
|
|
82
|
+
assert "never retrieved" not in server_log.read_text()[log_start:]
|
|
83
|
+
|
|
84
|
+
|
|
73
85
|
async def test_aborted_client_cancels_the_query(
|
|
74
86
|
server: str, server_log: Path, postgres_dsn: str, app_name: str
|
|
75
87
|
) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json
RENAMED
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql
RENAMED
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json
RENAMED
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql
RENAMED
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql
RENAMED
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|