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.
- pgdevkit/fastapi/json_response.py +31 -20
- pgdevkit/skills/pgdevkit/SKILL.md +1 -1
- {pgdevkit-0.14.0.dist-info → pgdevkit-0.14.2.dist-info}/METADATA +7 -6
- {pgdevkit-0.14.0.dist-info → pgdevkit-0.14.2.dist-info}/RECORD +6 -6
- {pgdevkit-0.14.0.dist-info → pgdevkit-0.14.2.dist-info}/WHEEL +0 -0
- {pgdevkit-0.14.0.dist-info → pgdevkit-0.14.2.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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) ->
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
batch
|
|
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
|
|
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.
|
|
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
|
|
525
|
-
(`%(name)s
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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=
|
|
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=
|
|
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.
|
|
52
|
-
pgdevkit-0.14.
|
|
53
|
-
pgdevkit-0.14.
|
|
54
|
-
pgdevkit-0.14.
|
|
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,,
|
|
File without changes
|
|
File without changes
|