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.
Files changed (152) hide show
  1. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/PKG-INFO +1 -1
  2. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/fastapi/cancel_on_disconnect.py +5 -1
  3. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/fastapi/json_response.py +8 -2
  4. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pyproject.toml +1 -1
  5. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/_asgi.py +22 -1
  6. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/_granian_app.py +5 -0
  7. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_cancel_on_disconnect.py +31 -2
  8. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_pg_json_cancel.py +24 -2
  9. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_pg_json_granian.py +12 -0
  10. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/uv.lock +1 -1
  11. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.github/workflows/auto-release.yml +0 -0
  12. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.github/workflows/python-publish.yml +0 -0
  13. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.github/workflows/python-test.yml +0 -0
  14. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.gitignore +0 -0
  15. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/.python-version +0 -0
  16. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/README.md +0 -0
  17. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/docs/database-layout.md +0 -0
  18. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/__init__.py +0 -0
  19. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/areas.py +0 -0
  20. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/backends/__init__.py +0 -0
  21. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/backends/base.py +0 -0
  22. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/backends/mssql.py +0 -0
  23. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/backends/postgres.py +0 -0
  24. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/cli.py +0 -0
  25. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/connection.py +0 -0
  26. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/__init__.py +0 -0
  27. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/complex_types.py +0 -0
  28. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/connection.py +0 -0
  29. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/crud.py +0 -0
  30. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/fetch.py +0 -0
  31. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/loader.py +0 -0
  32. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/model.py +0 -0
  33. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/mssql_crud.py +0 -0
  34. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/mssql_sql.py +0 -0
  35. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/db/transaction.py +0 -0
  36. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/dialect.py +0 -0
  37. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/diff.py +0 -0
  38. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/envtag.py +0 -0
  39. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/fastapi/__init__.py +0 -0
  40. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/fetch_missing.py +0 -0
  41. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/introspect.py +0 -0
  42. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/lakebase.py +0 -0
  43. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/migrate.py +0 -0
  44. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/migrate_mssql.py +0 -0
  45. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/models.py +0 -0
  46. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/mssql_introspect.py +0 -0
  47. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/parser.py +0 -0
  48. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/schemas.py +0 -0
  49. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/sql_text.py +0 -0
  50. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/stats.py +0 -0
  51. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/__init__.py +0 -0
  52. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/_docker.py +0 -0
  53. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/api.py +0 -0
  54. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/config.py +0 -0
  55. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/constants.py +0 -0
  56. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/container.py +0 -0
  57. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
  58. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/api.py +0 -0
  59. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/constants.py +0 -0
  60. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/container.py +0 -0
  61. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/naming.py +0 -0
  62. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/query.py +0 -0
  63. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/pgdevkit/testdb/schema.py +0 -0
  64. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/skills/pgdevkit/SKILL.md +0 -0
  65. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  66. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
  67. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/__init__.py +0 -0
  68. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/conftest.py +0 -0
  69. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/__init__.py +0 -0
  70. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_complex_types.py +0 -0
  71. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_connection.py +0 -0
  72. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_crud.py +0 -0
  73. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_fetch.py +0 -0
  74. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_fetch_statement_timeout.py +0 -0
  75. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_loader.py +0 -0
  76. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_mssql_crud_live.py +0 -0
  77. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_mssql_crud_sql.py +0 -0
  78. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_query_expr_types.py +0 -0
  79. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/db/test_transaction.py +0 -0
  80. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/01_schema.sql +0 -0
  81. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/02_types.sql +0 -0
  82. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/03_tables.sql +0 -0
  83. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/04_views.sql +0 -0
  84. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/05_functions.sql +0 -0
  85. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/fixtures/06_indexes.sql +0 -0
  86. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_areas.py +0 -0
  87. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_cli_compare.py +0 -0
  88. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_compare.py +0 -0
  89. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_compare_mssql_live.py +0 -0
  90. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_connection.py +0 -0
  91. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_dialect.py +0 -0
  92. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_diff_mssql.py +0 -0
  93. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_envtag.py +0 -0
  94. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_fetch_missing.py +0 -0
  95. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_fetch_missing_cli.py +0 -0
  96. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_lakebase.py +0 -0
  97. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate.py +0 -0
  98. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_areas.py +0 -0
  99. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_env.py +0 -0
  100. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_grants.py +0 -0
  101. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_mssql.py +0 -0
  102. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_mssql_live.py +0 -0
  103. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_migrate_schemas.py +0 -0
  104. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_mssql_introspect.py +0 -0
  105. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_parser_areas.py +0 -0
  106. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_parser_mssql.py +0 -0
  107. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_parser_schemas.py +0 -0
  108. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_pg_json_response.py +0 -0
  109. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_pg_json_statement_timeout.py +0 -0
  110. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_schemas.py +0 -0
  111. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_stats_cli.py +0 -0
  112. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/test_stats_mssql_live.py +0 -0
  113. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/__init__.py +0 -0
  114. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/conftest.py +0 -0
  115. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  116. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  117. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  118. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  119. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  120. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  121. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  122. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  123. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  124. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  125. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  126. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  127. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  128. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  129. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  130. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  131. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  132. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  133. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  134. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  135. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  136. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  137. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_api.py +0 -0
  138. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_api_mssql.py +0 -0
  139. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_api_mssql_live.py +0 -0
  140. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_cli.py +0 -0
  141. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_config.py +0 -0
  142. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_config_mssql.py +0 -0
  143. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_constants.py +0 -0
  144. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_container.py +0 -0
  145. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_container_mssql.py +0 -0
  146. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_docker.py +0 -0
  147. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_mssql_constants.py +0 -0
  148. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_naming.py +0 -0
  149. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_query.py +0 -0
  150. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_schema.py +0 -0
  151. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_schema_filtering.py +0 -0
  152. {pgdevkit-0.16.0 → pgdevkit-0.16.1}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.16.0
3
+ Version: 0.16.1
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.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
- cancel_scope.reschedule(loop.time())
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
- while (await receive())["type"] != "http.disconnect":
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
- cancel_scope.reschedule(loop.time()) # i.e. cancel the stream now
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():
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.16.0"
14
+ version = "0.16.1"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -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.requests import ClientDisconnect
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:
@@ -451,7 +451,7 @@ wheels = [
451
451
 
452
452
  [[package]]
453
453
  name = "pgdevkit"
454
- version = "0.16.0"
454
+ version = "0.16.1"
455
455
  source = { editable = "." }
456
456
  dependencies = [
457
457
  { name = "docker" },
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