pgdevkit 0.14.0__py3-none-any.whl → 0.14.2__py3-none-any.whl

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.
@@ -6,9 +6,10 @@ Requires the ``fastapi`` extra: ``pip install pgdevkit[fastapi]``.
6
6
  import asyncio
7
7
  import json
8
8
  import logging
9
- from collections.abc import AsyncIterator, Mapping
9
+ from contextlib import aclosing
10
+ from collections.abc import AsyncGenerator, Mapping
10
11
  from string.templatelib import Template
11
- from typing import Any
12
+ from typing import Any, cast
12
13
 
13
14
  import anyio
14
15
  from fastapi.responses import StreamingResponse
@@ -24,7 +25,7 @@ from starlette.types import Message, Receive, Scope, Send
24
25
  from ..db.fetch import (
25
26
  ConnectionSource,
26
27
  QueryParams,
27
- SqlQueryNoTemplate,
28
+ SqlQuery,
28
29
  _acquire,
29
30
  _check_statement_timeout,
30
31
  _render,
@@ -45,7 +46,8 @@ class PostgresJsonResponse(StreamingResponse):
45
46
  response has been sent, e.g. a ``Depends`` with ``yield``), or ``pool`` (anything with ``.connection()``), or
46
47
  register the app's pool once with ``set_default_pool()``. A pooled connection is acquired when streaming starts
47
48
  and released when it ends. ``query`` and ``params`` are those of ``fetch_all`` too (a literal string, a
48
- ``psycopg.sql`` composable or a sqlglot expression; never a t-string) with values bound via ``params``.
49
+ ``psycopg.sql`` composable, a sqlglot expression or a t-string) with values bound via ``params``; a t-string carries
50
+ its own values and takes no ``params``.
49
51
 
50
52
  The query is wrapped as a subquery, so it cannot be a data-modifying CTE; for that (or for custom JSON) pass
51
53
  ``query_produces_json=True`` and make it return one *text* column holding the JSON of each row.
@@ -71,7 +73,7 @@ class PostgresJsonResponse(StreamingResponse):
71
73
 
72
74
  def __init__(
73
75
  self,
74
- query: SqlQueryNoTemplate,
76
+ query: SqlQuery,
75
77
  params: QueryParams = None,
76
78
  *,
77
79
  con: AsyncConnection | None = None,
@@ -87,8 +89,8 @@ class PostgresJsonResponse(StreamingResponse):
87
89
  if batch_size < 1:
88
90
  raise ValueError(f"batch_size must be >= 1, got {batch_size}")
89
91
  _check_statement_timeout(statement_timeout)
90
- if isinstance(query, Template):
91
- raise TypeError("t-strings can't be wrapped in a subquery: use a literal string or psycopg.sql with params")
92
+ if isinstance(query, Template) and params is not None:
93
+ raise TypeError("A t-string query carries its own values; don't pass `params` with it.")
92
94
  rendered = _render(query)
93
95
  self.query = rendered if query_produces_json else _as_json_rows(rendered)
94
96
  self.params = params
@@ -128,11 +130,14 @@ class PostgresJsonResponse(StreamingResponse):
128
130
  try:
129
131
  async with _acquire(self.con, self.pool) as conn:
130
132
  try:
131
- async for chunk in self._chunks(conn):
132
- if not started:
133
- await _send(send, self._start_message(self.status_code, self.raw_headers))
134
- started = True
135
- await _send(send, {"type": "http.response.body", "body": chunk, "more_body": True})
133
+ # aclosing: an `async for` leaves its generator suspended when the body fails (e.g. send()
134
+ # raising); the event loop would finalize it later, after the connection is closed.
135
+ async with aclosing(self._chunks(conn)) as chunks:
136
+ async for chunk in chunks:
137
+ if not started:
138
+ await _send(send, self._start_message(self.status_code, self.raw_headers))
139
+ started = True
140
+ await _send(send, {"type": "http.response.body", "body": chunk, "more_body": True})
136
141
  finally:
137
142
  # Runs for completion too, but only acts if the query is still in flight.
138
143
  await _cancel_if_running(conn)
@@ -171,7 +176,7 @@ class PostgresJsonResponse(StreamingResponse):
171
176
  await _send(send, self._start_message(status, headers))
172
177
  await _send(send, {"type": "http.response.body", "body": body, "more_body": False})
173
178
 
174
- async def _chunks(self, conn: AsyncConnection) -> AsyncIterator[bytes]:
179
+ async def _chunks(self, conn: AsyncConnection) -> AsyncGenerator[bytes]:
175
180
  """Yield the JSON array in pieces of ``batch_size`` rows. Nothing is yielded before the first batch is
176
181
  complete, so a failing query surfaces before any byte (and the status line) is sent."""
177
182
  if self.statement_timeout is not None and conn.autocommit:
@@ -183,19 +188,25 @@ class PostgresJsonResponse(StreamingResponse):
183
188
  _statement_timeout(conn, self.statement_timeout),
184
189
  conn.cursor(row_factory=tuple_row) as cur, # whatever the connection was configured with
185
190
  ):
186
- rows = cur.stream(self.query, self.params, size=self.batch_size if _CHUNKED_ROWS else 1)
187
- async for (row_json,) in rows:
188
- batch.append(row_json)
189
- if len(batch) >= self.batch_size:
190
- yield (prefix + ",\n".join(batch)).encode()
191
- batch, prefix = [], ","
191
+ # (typed as an iterator, but it is an async generator, which is what aclosing() needs)
192
+ size = self.batch_size if _CHUNKED_ROWS else 1
193
+ rows = cast("AsyncGenerator[tuple[Any, ...]]", cur.stream(self.query, self.params, size=size))
194
+ async with aclosing(rows): # same reason as in stream_response(): finish psycopg's cleanup in order
195
+ async for (row_json,) in rows:
196
+ batch.append(row_json)
197
+ if len(batch) >= self.batch_size:
198
+ yield (prefix + ",\n".join(batch)).encode()
199
+ batch, prefix = [], ","
192
200
  if batch or prefix == "[": # remaining rows, or an empty result
193
201
  yield (prefix + ",\n".join(batch) + "]").encode()
194
202
  else: # the last batch was full and has been sent already
195
203
  yield b"]"
196
204
 
197
205
 
198
- def _as_json_rows(query: Any) -> Composed:
206
+ def _as_json_rows(query: Any) -> Composed | Template:
207
+ if isinstance(query, Template):
208
+ # psycopg expands a Template nested with `:q` in place, its own values staying bound parameters
209
+ return t"select row_to_json(s)::text from (\n{query:q}\n) s"
199
210
  if isinstance(query, Composable):
200
211
  inner = query
201
212
  else:
@@ -272,7 +272,7 @@ async def articles(lng: str) -> PostgresJsonResponse:
272
272
  return PostgresJsonResponse(sql.load_sql("articles", "list_articles"), {"lng": lng})
273
273
  ```
274
274
 
275
- - Query: from a `.sql` file via `SqlLoader` like any non-trivial query (or `psycopg.sql`/sqlglot), values via `params` (`%(name)s`); a literal string is enforced by the type checker only, never build it from user input. Data-modifying CTE or custom JSON: `query_produces_json=True` (one text column per row).
275
+ - Query: from a `.sql` file via `SqlLoader` like any non-trivial query (or `psycopg.sql`, sqlglot or a t-string for dynamic SQL), values via `params` (`%(name)s`; none with a t-string, it carries its own). That it is a literal is enforced by the type checker only: never build it from user input. Data-modifying CTE or custom JSON: `query_produces_json=True` (one text column per row).
276
276
  - `responses={200: {"model": ...}}` keeps the OpenAPI schema typed for the generated frontend client; `response_model` is ignored and rows are **not validated**.
277
277
  - Connections as in `fetch_all`: default pool, `pool=`, or `con=`. Prefer the pool: a `con` you opened must outlive the response (`Depends` with `yield`, never `async with ... as conn: return PostgresJsonResponse(q, con=conn)`), and a disconnect aborts its transaction.
278
278
  - Errors show as `{"error": "Internal Server Error"}` (500); `expose_errors = IS_DEV` in a subclass shows the text (leaks SQL/values: never in prod). An error after the first byte leaves the array unterminated, so clients fail to parse it.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.14.0
3
+ Version: 0.14.2
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -521,11 +521,12 @@ for a `Response`, and the rows are *not* validated against the model.
521
521
  starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
522
522
  with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
523
523
  closes it too early. Prefer the pool.
524
- - **Query** — a literal string, a `psycopg.sql` composable or a sqlglot expression, with values bound through `params`
525
- (`%(name)s`). Passing only literals is enforced by the type checker, **not at runtime**: the query is wrapped
526
- as a subquery, never parsed, so never build it from user input. For a data-modifying CTE, or custom JSON, pass
527
- `query_produces_json=True` and return one *text* column per row (a `json` column or NULL fails). A trailing `;`
528
- is fine, a `;` followed by a comment is not. t-strings aren't supported (they can't be wrapped).
524
+ - **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression or a t-string, with values bound
525
+ through `params` (`%(name)s`; a t-string carries its own values and takes no `params`). Passing only literals
526
+ is enforced by the type checker, **not at runtime**: the query is wrapped as a subquery, never parsed, so never
527
+ build it from user input. For a data-modifying CTE, or custom JSON, pass `query_produces_json=True` and return one
528
+ *text* column per row (a `json` column or NULL fails). A trailing `;` is fine in a string, a `;` followed by a
529
+ comment is not.
529
530
  - **Options** — `batch_size` (rows per chunk, >= 1, default 1000; don't take it from user input),
530
531
  `statement_timeout` (seconds, see below), `status_code`, `headers` (e.g. `{"Cache-Control": "max-age=3600"}`).
531
532
 
@@ -30,7 +30,7 @@ pgdevkit/db/model.py,sha256=ntuXonq-SCGkbNw-lvdMS5VuzLjxYRT8O6xaVqiw56I,980
30
30
  pgdevkit/db/mssql_crud.py,sha256=yN4izZ0fsFwAs14008JECHH4dqjNbdHUHGEG8kidyNs,11374
31
31
  pgdevkit/db/mssql_sql.py,sha256=DO3UMsTFN9pg0tNdaPGb0wf6lWlHGcqwqzkrGVhJF2g,2292
32
32
  pgdevkit/fastapi/__init__.py,sha256=Oku0U7wNblWJj_wbyiw_ik299vAsdl1fX6FCth0KliI,84
33
- pgdevkit/fastapi/json_response.py,sha256=WtQfcYYts4MT0ag5u42nAIdexTJelVsi8umH5dF0Egs,12167
33
+ pgdevkit/fastapi/json_response.py,sha256=9NbsHeGARrhKoORIFEJtMivprXyHe_QvGgu6CEK0DKk,13058
34
34
  pgdevkit/testdb/__init__.py,sha256=tphuORo4t0QHQp_gFi6ZHXXgd32w6qUoKlaQCab6izA,366
35
35
  pgdevkit/testdb/_docker.py,sha256=-W59YBbcSIl9H_YmySRtVbOV4ImZPHOq9hxmlO-p56s,2807
36
36
  pgdevkit/testdb/api.py,sha256=13YgTz6hPcYWfsUlQJhJ7k20-W_4hzepedVaT8gWhxI,9709
@@ -45,10 +45,10 @@ pgdevkit/testdb/mssql/api.py,sha256=Ah95QTsOLvtubyHxRWDywjX9NCvTVgurmANLP5RY2Ck,
45
45
  pgdevkit/testdb/mssql/constants.py,sha256=qyA1FCv-2LAQ_C8OydePEXhyG_ZBPPB7vg1686xVpVU,3453
46
46
  pgdevkit/testdb/mssql/container.py,sha256=sqJPAuhN1by3kZZ96gLet4pRt3uJmUMO6Kkmvjydg1w,3791
47
47
  pgdevkit/docs/database-layout.md,sha256=j9N8GuT63D2oVf9rIOEv8GTWs4yTtjZTGH-zM5Wrg5o,8163
48
- pgdevkit/skills/pgdevkit/SKILL.md,sha256=vN9TlRYCmidx4shFY0S0XPiVmK6q5Rr714uGhFDCcsE,19181
48
+ pgdevkit/skills/pgdevkit/SKILL.md,sha256=pAJF-Xd6wLeiGJ2VPyOfVaSIw1yWUNczjA-x4HICz_A,19258
49
49
  pgdevkit/skills/pgdevkit/references/dynamic-sql.md,sha256=jA-sO8Z10CaFFjYhGHlzZFrvNeXxj1QiAxoCn8RPPVc,2974
50
50
  pgdevkit/skills/pgdevkit/references/temporal-tables.md,sha256=LwZnItvQWI7bB0oRHxIfUc0ynnq7N0V5Mnv-QHvFN3Q,1364
51
- pgdevkit-0.14.0.dist-info/METADATA,sha256=lOm-KoqZLippFCZBum1dEE_-eDsnjtQdzljgkv-VOLQ,31252
52
- pgdevkit-0.14.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
53
- pgdevkit-0.14.0.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
54
- pgdevkit-0.14.0.dist-info/RECORD,,
51
+ pgdevkit-0.14.2.dist-info/METADATA,sha256=QD23CSUwKMdpV_AesUYjoM15XeHW1ftPNHt_WbL5YcU,31283
52
+ pgdevkit-0.14.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
53
+ pgdevkit-0.14.2.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
54
+ pgdevkit-0.14.2.dist-info/RECORD,,