pgdevkit 0.13.0__tar.gz → 0.14.2__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 (147) hide show
  1. pgdevkit-0.13.0/README.md → pgdevkit-0.14.2/PKG-INFO +122 -3
  2. pgdevkit-0.13.0/PKG-INFO → pgdevkit-0.14.2/README.md +97 -24
  3. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/fetch.py +52 -6
  4. pgdevkit-0.14.2/pgdevkit/fastapi/__init__.py +3 -0
  5. pgdevkit-0.14.2/pgdevkit/fastapi/json_response.py +259 -0
  6. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pyproject.toml +7 -1
  7. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/skills/pgdevkit/SKILL.md +29 -4
  8. pgdevkit-0.14.2/tests/_asgi.py +53 -0
  9. pgdevkit-0.14.2/tests/_granian_app.py +40 -0
  10. pgdevkit-0.14.2/tests/db/test_fetch_statement_timeout.py +61 -0
  11. pgdevkit-0.14.2/tests/test_pg_json_cancel.py +199 -0
  12. pgdevkit-0.14.2/tests/test_pg_json_granian.py +98 -0
  13. pgdevkit-0.14.2/tests/test_pg_json_response.py +277 -0
  14. pgdevkit-0.14.2/tests/test_pg_json_statement_timeout.py +90 -0
  15. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/uv.lock +167 -2
  16. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/.github/workflows/auto-release.yml +0 -0
  17. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/.github/workflows/python-publish.yml +0 -0
  18. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/.github/workflows/python-test.yml +0 -0
  19. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/.gitignore +0 -0
  20. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/.python-version +0 -0
  21. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/docs/database-layout.md +0 -0
  22. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/__init__.py +0 -0
  23. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/areas.py +0 -0
  24. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/backends/__init__.py +0 -0
  25. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/backends/base.py +0 -0
  26. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/backends/mssql.py +0 -0
  27. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/backends/postgres.py +0 -0
  28. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/cli.py +0 -0
  29. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/connection.py +0 -0
  30. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/__init__.py +0 -0
  31. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/complex_types.py +0 -0
  32. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/connection.py +0 -0
  33. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/crud.py +0 -0
  34. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/loader.py +0 -0
  35. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/model.py +0 -0
  36. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/mssql_crud.py +0 -0
  37. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/db/mssql_sql.py +0 -0
  38. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/dialect.py +0 -0
  39. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/diff.py +0 -0
  40. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/envtag.py +0 -0
  41. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/fetch_missing.py +0 -0
  42. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/introspect.py +0 -0
  43. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/lakebase.py +0 -0
  44. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/migrate.py +0 -0
  45. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/migrate_mssql.py +0 -0
  46. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/models.py +0 -0
  47. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/mssql_introspect.py +0 -0
  48. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/parser.py +0 -0
  49. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/schemas.py +0 -0
  50. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/sql_text.py +0 -0
  51. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/stats.py +0 -0
  52. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/__init__.py +0 -0
  53. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/_docker.py +0 -0
  54. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/api.py +0 -0
  55. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/config.py +0 -0
  56. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/constants.py +0 -0
  57. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/container.py +0 -0
  58. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/mssql/__init__.py +0 -0
  59. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/mssql/api.py +0 -0
  60. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/mssql/constants.py +0 -0
  61. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/mssql/container.py +0 -0
  62. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/naming.py +0 -0
  63. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/query.py +0 -0
  64. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/pgdevkit/testdb/schema.py +0 -0
  65. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  66. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/skills/pgdevkit/references/temporal-tables.md +0 -0
  67. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/__init__.py +0 -0
  68. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/conftest.py +0 -0
  69. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/db/__init__.py +0 -0
  70. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/db/test_complex_types.py +0 -0
  71. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/db/test_connection.py +0 -0
  72. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/db/test_crud.py +0 -0
  73. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/db/test_fetch.py +0 -0
  74. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/db/test_loader.py +0 -0
  75. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/db/test_mssql_crud_live.py +0 -0
  76. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/db/test_mssql_crud_sql.py +0 -0
  77. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/fixtures/01_schema.sql +0 -0
  78. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/fixtures/02_types.sql +0 -0
  79. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/fixtures/03_tables.sql +0 -0
  80. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/fixtures/04_views.sql +0 -0
  81. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/fixtures/05_functions.sql +0 -0
  82. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/fixtures/06_indexes.sql +0 -0
  83. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_areas.py +0 -0
  84. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_cli_compare.py +0 -0
  85. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_compare.py +0 -0
  86. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_compare_mssql_live.py +0 -0
  87. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_connection.py +0 -0
  88. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_dialect.py +0 -0
  89. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_diff_mssql.py +0 -0
  90. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_envtag.py +0 -0
  91. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_fetch_missing.py +0 -0
  92. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_fetch_missing_cli.py +0 -0
  93. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_lakebase.py +0 -0
  94. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_migrate.py +0 -0
  95. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_migrate_areas.py +0 -0
  96. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_migrate_env.py +0 -0
  97. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_migrate_grants.py +0 -0
  98. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_migrate_mssql.py +0 -0
  99. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_migrate_mssql_live.py +0 -0
  100. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_migrate_schemas.py +0 -0
  101. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_mssql_introspect.py +0 -0
  102. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_parser_areas.py +0 -0
  103. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_parser_mssql.py +0 -0
  104. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_parser_schemas.py +0 -0
  105. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_schemas.py +0 -0
  106. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_stats_cli.py +0 -0
  107. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/test_stats_mssql_live.py +0 -0
  108. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/__init__.py +0 -0
  109. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/conftest.py +0 -0
  110. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  111. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  112. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  113. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  114. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  115. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  116. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  117. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  118. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  119. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  120. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  121. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  122. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  123. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  124. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  125. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  126. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  127. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  128. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  129. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  130. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  131. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  132. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_api.py +0 -0
  133. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_api_mssql.py +0 -0
  134. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_api_mssql_live.py +0 -0
  135. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_cli.py +0 -0
  136. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_config.py +0 -0
  137. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_config_mssql.py +0 -0
  138. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_constants.py +0 -0
  139. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_container.py +0 -0
  140. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_container_mssql.py +0 -0
  141. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_docker.py +0 -0
  142. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_mssql_constants.py +0 -0
  143. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_naming.py +0 -0
  144. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_query.py +0 -0
  145. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_schema.py +0 -0
  146. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_schema_filtering.py +0 -0
  147. {pgdevkit-0.13.0 → pgdevkit-0.14.2}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,3 +1,28 @@
1
+ Metadata-Version: 2.5
2
+ Name: pgdevkit
3
+ Version: 0.14.2
4
+ Summary: A helper for developing with Postgres
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: docker>=7.1.0
7
+ Requires-Dist: psycopg[binary]>=3.3.0
8
+ Requires-Dist: sqlglot>=30.11.0
9
+ Provides-Extra: azure
10
+ Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
11
+ Provides-Extra: cli
12
+ Requires-Dist: rich>=13.0.0; extra == 'cli'
13
+ Requires-Dist: tqdm>=4.66.0; extra == 'cli'
14
+ Requires-Dist: typer>=0.26.7; extra == 'cli'
15
+ Provides-Extra: db
16
+ Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
17
+ Requires-Dist: pydantic>=2.0; extra == 'db'
18
+ Provides-Extra: fastapi
19
+ Requires-Dist: fastapi>=0.118.0; extra == 'fastapi'
20
+ Requires-Dist: psycopg-pool>=3.3.0; extra == 'fastapi'
21
+ Requires-Dist: pydantic>=2.0; extra == 'fastapi'
22
+ Provides-Extra: mssql
23
+ Requires-Dist: mssql-python>=1.0.0; extra == 'mssql'
24
+ Description-Content-Type: text/markdown
25
+
1
26
  # pgdevkit
2
27
 
3
28
  A helper for developing with Postgres.
@@ -406,14 +431,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
406
431
  `--entra-user`. For Lakebase hosts, also set the
407
432
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
408
433
  env vars.
409
- - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
434
+ - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=None)`**
410
435
  — run one custom query and get every row back as dicts, as validated
411
436
  Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
412
437
  connection you already hold (it is borrowed: never committed, closed or
413
438
  released); otherwise a connection is taken from `pool` or the pool registered
414
439
  with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
415
- (`QueryCanceled`); cancelling the awaiting task cancels it there too, and
416
- `CancelledError` propagates as usual. `query` must be a literal string, a sqlglot
440
+ (`QueryCanceled`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
441
+ disconnect itself); cancelling the awaiting task cancels it there too, and
442
+ `CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
443
+ long (`QueryCanceled`, "canceling statement due to statement timeout"); it is applied per call with `SET LOCAL`
444
+ semantics, so it never outlives the call (a borrowed `con` gets its previous setting back). `query` must be a literal string, a sqlglot
417
445
  expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
418
446
  expression is only as safe as the strings it was built from: pass user values as
419
447
  `exp.Placeholder` + `params`).
@@ -461,6 +489,97 @@ async with pool.connection() as con:
461
489
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
462
490
  ```
463
491
 
492
+ ## `pgdevkit.fastapi` — streaming JSON responses
493
+
494
+ Requires the `fastapi` extra (`pip install pgdevkit[fastapi]`, FastAPI >= 0.118).
495
+
496
+ `PostgresJsonResponse` runs a query and streams its rows to the client as a JSON array. Postgres builds the JSON
497
+ (`row_to_json`), so there is no row-by-row Python serialization and no pydantic round trip, and memory use stays flat
498
+ for big results. **If the client goes away, the query is cancelled in Postgres** instead of running to completion.
499
+ (`fetch_all(..., cancel=event)` cancels a buffered query too, but you wire the event to the disconnect yourself.)
500
+
501
+ ```python
502
+ from pgdevkit.db import PgPool, set_default_pool
503
+ from pgdevkit.fastapi import PostgresJsonResponse
504
+
505
+ pool = PgPool(env_prefix="APP_POSTGRES_")
506
+ set_default_pool(pool) # once at startup, shared with fetch_all
507
+
508
+ @router.get("/articles", responses={200: {"model": list[ArticleOut]}})
509
+ async def articles(lng: str) -> PostgresJsonResponse:
510
+ return PostgresJsonResponse(
511
+ "select id, name from articles where lng = %(lng)s order by name",
512
+ {"lng": lng},
513
+ )
514
+ ```
515
+
516
+ The `responses=` entry keeps the OpenAPI schema (and a generated frontend client) typed; `response_model` is ignored
517
+ for a `Response`, and the rows are *not* validated against the model.
518
+
519
+ - **Connections** work as in `fetch_all`: the pool registered with `set_default_pool()`, an explicit `pool=` (anything
520
+ with `.connection()`, e.g. `PgPool`), or a `con=` you opened yourself. A pooled connection is acquired when streaming
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
+ with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
523
+ closes it too early. Prefer the pool.
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.
530
+ - **Options** — `batch_size` (rows per chunk, >= 1, default 1000; don't take it from user input),
531
+ `statement_timeout` (seconds, see below), `status_code`, `headers` (e.g. `{"Cache-Control": "max-age=3600"}`).
532
+
533
+ ### Cancellation
534
+
535
+ The response watches for `http.disconnect` itself (Starlette only does that for ASGI spec < 2.4), also while the
536
+ query is still running and nothing has been sent yet. It then cancels the query with `cancel_safe()`, shielded from
537
+ the cancellation that is tearing the request down, and closes the connection if it was left mid-query: a pool opens a
538
+ fresh one instead of reusing it. Only a query that is still running is cancelled, so a late cancel can never
539
+ hit the next statement on a reused connection. A frontend `AbortController` / closed tab therefore frees the database.
540
+
541
+ With `con=`, a disconnect aborts the query *and your transaction*, unlike `fetch_all`: the connection is afterwards
542
+ either left in an aborted transaction (idle, with autocommit) or closed, depending on whether the stream was waiting for
543
+ Postgres or for the client.
544
+ Don't use it afterwards, and don't count on earlier writes in that transaction.
545
+
546
+ Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
547
+ help, see below). Enforce a write timeout in the reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
548
+ (`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
549
+
550
+ ### Statement timeout
551
+
552
+ `statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
553
+ is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
554
+ behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
555
+ the client gets a `504 {"error": "Query timed out"}`; after that the response is cut short like any other error. It
556
+ needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
557
+
558
+ It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
559
+ has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
560
+ in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
561
+
562
+ ### Errors
563
+
564
+ An error before the first byte becomes a JSON `{"error": ...}` response (500, or the status and headers of an
565
+ `HTTPException` raised by a custom pool); the text is hidden unless you subclass and set `expose_errors = True`
566
+ (it contains SQL fragments and parameter values: never in production):
567
+
568
+ ```python
569
+ class AppJsonResponse(PostgresJsonResponse):
570
+ expose_errors = IS_DEV
571
+ ```
572
+
573
+ After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
574
+ the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
575
+ apps run on, ends such a response cleanly with the truncated body.)
576
+
577
+ ### Notes
578
+
579
+ It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
580
+ result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
581
+ so a slow query with tiny rows delivers late either way.
582
+
464
583
  ## Releasing
465
584
 
466
585
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -1,24 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: pgdevkit
3
- Version: 0.13.0
4
- Summary: A helper for developing with Postgres
5
- Requires-Python: >=3.14
6
- Requires-Dist: docker>=7.1.0
7
- Requires-Dist: psycopg[binary]>=3.3.0
8
- Requires-Dist: sqlglot>=30.11.0
9
- Provides-Extra: azure
10
- Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
11
- Provides-Extra: cli
12
- Requires-Dist: rich>=13.0.0; extra == 'cli'
13
- Requires-Dist: tqdm>=4.66.0; extra == 'cli'
14
- Requires-Dist: typer>=0.26.7; extra == 'cli'
15
- Provides-Extra: db
16
- Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
17
- Requires-Dist: pydantic>=2.0; extra == 'db'
18
- Provides-Extra: mssql
19
- Requires-Dist: mssql-python>=1.0.0; extra == 'mssql'
20
- Description-Content-Type: text/markdown
21
-
22
1
  # pgdevkit
23
2
 
24
3
  A helper for developing with Postgres.
@@ -427,14 +406,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
427
406
  `--entra-user`. For Lakebase hosts, also set the
428
407
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
429
408
  env vars.
430
- - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
409
+ - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=None)`**
431
410
  — run one custom query and get every row back as dicts, as validated
432
411
  Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
433
412
  connection you already hold (it is borrowed: never committed, closed or
434
413
  released); otherwise a connection is taken from `pool` or the pool registered
435
414
  with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
436
- (`QueryCanceled`); cancelling the awaiting task cancels it there too, and
437
- `CancelledError` propagates as usual. `query` must be a literal string, a sqlglot
415
+ (`QueryCanceled`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
416
+ disconnect itself); cancelling the awaiting task cancels it there too, and
417
+ `CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
418
+ long (`QueryCanceled`, "canceling statement due to statement timeout"); it is applied per call with `SET LOCAL`
419
+ semantics, so it never outlives the call (a borrowed `con` gets its previous setting back). `query` must be a literal string, a sqlglot
438
420
  expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
439
421
  expression is only as safe as the strings it was built from: pass user values as
440
422
  `exp.Placeholder` + `params`).
@@ -482,6 +464,97 @@ async with pool.connection() as con:
482
464
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
483
465
  ```
484
466
 
467
+ ## `pgdevkit.fastapi` — streaming JSON responses
468
+
469
+ Requires the `fastapi` extra (`pip install pgdevkit[fastapi]`, FastAPI >= 0.118).
470
+
471
+ `PostgresJsonResponse` runs a query and streams its rows to the client as a JSON array. Postgres builds the JSON
472
+ (`row_to_json`), so there is no row-by-row Python serialization and no pydantic round trip, and memory use stays flat
473
+ for big results. **If the client goes away, the query is cancelled in Postgres** instead of running to completion.
474
+ (`fetch_all(..., cancel=event)` cancels a buffered query too, but you wire the event to the disconnect yourself.)
475
+
476
+ ```python
477
+ from pgdevkit.db import PgPool, set_default_pool
478
+ from pgdevkit.fastapi import PostgresJsonResponse
479
+
480
+ pool = PgPool(env_prefix="APP_POSTGRES_")
481
+ set_default_pool(pool) # once at startup, shared with fetch_all
482
+
483
+ @router.get("/articles", responses={200: {"model": list[ArticleOut]}})
484
+ async def articles(lng: str) -> PostgresJsonResponse:
485
+ return PostgresJsonResponse(
486
+ "select id, name from articles where lng = %(lng)s order by name",
487
+ {"lng": lng},
488
+ )
489
+ ```
490
+
491
+ The `responses=` entry keeps the OpenAPI schema (and a generated frontend client) typed; `response_model` is ignored
492
+ for a `Response`, and the rows are *not* validated against the model.
493
+
494
+ - **Connections** work as in `fetch_all`: the pool registered with `set_default_pool()`, an explicit `pool=` (anything
495
+ with `.connection()`, e.g. `PgPool`), or a `con=` you opened yourself. A pooled connection is acquired when streaming
496
+ starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
497
+ with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
498
+ closes it too early. Prefer the pool.
499
+ - **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression or a t-string, with values bound
500
+ through `params` (`%(name)s`; a t-string carries its own values and takes no `params`). Passing only literals
501
+ is enforced by the type checker, **not at runtime**: the query is wrapped as a subquery, never parsed, so never
502
+ build it from user input. For a data-modifying CTE, or custom JSON, pass `query_produces_json=True` and return one
503
+ *text* column per row (a `json` column or NULL fails). A trailing `;` is fine in a string, a `;` followed by a
504
+ comment is not.
505
+ - **Options** — `batch_size` (rows per chunk, >= 1, default 1000; don't take it from user input),
506
+ `statement_timeout` (seconds, see below), `status_code`, `headers` (e.g. `{"Cache-Control": "max-age=3600"}`).
507
+
508
+ ### Cancellation
509
+
510
+ The response watches for `http.disconnect` itself (Starlette only does that for ASGI spec < 2.4), also while the
511
+ query is still running and nothing has been sent yet. It then cancels the query with `cancel_safe()`, shielded from
512
+ the cancellation that is tearing the request down, and closes the connection if it was left mid-query: a pool opens a
513
+ fresh one instead of reusing it. Only a query that is still running is cancelled, so a late cancel can never
514
+ hit the next statement on a reused connection. A frontend `AbortController` / closed tab therefore frees the database.
515
+
516
+ With `con=`, a disconnect aborts the query *and your transaction*, unlike `fetch_all`: the connection is afterwards
517
+ either left in an aborted transaction (idle, with autocommit) or closed, depending on whether the stream was waiting for
518
+ Postgres or for the client.
519
+ Don't use it afterwards, and don't count on earlier writes in that transaction.
520
+
521
+ Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
522
+ help, see below). Enforce a write timeout in the reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
523
+ (`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
524
+
525
+ ### Statement timeout
526
+
527
+ `statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
528
+ is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
529
+ behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
530
+ the client gets a `504 {"error": "Query timed out"}`; after that the response is cut short like any other error. It
531
+ needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
532
+
533
+ It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
534
+ has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
535
+ in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
536
+
537
+ ### Errors
538
+
539
+ An error before the first byte becomes a JSON `{"error": ...}` response (500, or the status and headers of an
540
+ `HTTPException` raised by a custom pool); the text is hidden unless you subclass and set `expose_errors = True`
541
+ (it contains SQL fragments and parameter values: never in production):
542
+
543
+ ```python
544
+ class AppJsonResponse(PostgresJsonResponse):
545
+ expose_errors = IS_DEV
546
+ ```
547
+
548
+ After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
549
+ the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
550
+ apps run on, ends such a response cleanly with the truncated body.)
551
+
552
+ ### Notes
553
+
554
+ It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
555
+ result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
556
+ so a slow query with tiny rows delivers late either way.
557
+
485
558
  ## Releasing
486
559
 
487
560
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -2,9 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import math
5
6
  import re
6
7
  from collections.abc import AsyncIterator, Callable, Mapping, Sequence
7
- from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
8
+ from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager, suppress
8
9
  from string.templatelib import Template
9
10
  from typing import TYPE_CHECKING, Any, LiteralString, Protocol, overload
10
11
 
@@ -84,8 +85,37 @@ def _render(query: SqlQuery) -> Any:
84
85
  return query.sql(dialect="postgres")
85
86
 
86
87
 
87
- async def _run_query(c: AsyncConnection, query: Any, params: QueryParams) -> list[dict[str, Any]]:
88
- async with c.cursor(row_factory=dict_row) as cur:
88
+ def _check_statement_timeout(statement_timeout: float | None) -> None:
89
+ if statement_timeout is not None and statement_timeout <= 0:
90
+ raise ValueError(f"statement_timeout must be a positive number of seconds, got {statement_timeout}")
91
+
92
+
93
+ @asynccontextmanager
94
+ async def _statement_timeout(c: AsyncConnection, seconds: float | None) -> AsyncIterator[None]:
95
+ """Run the block under Postgres' `statement_timeout`, set with `set_config(..., is_local => true)`: it is
96
+ scoped to the current transaction (so it can't leak into a pool, and it is safe behind PgBouncer's
97
+ transaction pooling) and put back afterwards for a borrowed connection that stays in its transaction.
98
+ `None` leaves the connection's own setting alone."""
99
+ if seconds is None:
100
+ yield
101
+ return
102
+ async with AsyncExitStack() as stack:
103
+ if c.autocommit:
104
+ await stack.enter_async_context(c.transaction()) # a local setting needs a transaction to live in
105
+ cur = await c.execute(
106
+ "SELECT current_setting('statement_timeout'), set_config('statement_timeout', %(ms)s, true)",
107
+ {"ms": str(max(1, math.ceil(seconds * 1000)))},
108
+ )
109
+ row = await cur.fetchone()
110
+ assert row is not None
111
+ yield
112
+ await c.execute("SELECT set_config('statement_timeout', %(previous)s, true)", {"previous": row[0]})
113
+
114
+
115
+ async def _run_query(
116
+ c: AsyncConnection, query: Any, params: QueryParams, statement_timeout: float | None
117
+ ) -> list[dict[str, Any]]:
118
+ async with _statement_timeout(c, statement_timeout), c.cursor(row_factory=dict_row) as cur:
89
119
  await cur.execute(query, params)
90
120
  return await cur.fetchall()
91
121
 
@@ -129,11 +159,12 @@ async def _fetch_dicts(
129
159
  con: AsyncConnection | None,
130
160
  pool: ConnectionSource | None,
131
161
  cancel: asyncio.Event | None,
162
+ statement_timeout: float | None,
132
163
  ) -> list[dict[str, Any]]:
133
164
  if cancel is not None and cancel.is_set():
134
165
  raise QueryCanceled("fetch_all: cancelled before the query started")
135
166
  async with _acquire(con, pool) as c:
136
- work = asyncio.ensure_future(_run_query(c, query, params))
167
+ work = asyncio.ensure_future(_run_query(c, query, params, statement_timeout))
137
168
  stop = asyncio.ensure_future(cancel.wait()) if cancel is not None else None
138
169
  try:
139
170
  await asyncio.wait([work, *([stop] if stop else [])], return_when=asyncio.FIRST_COMPLETED)
@@ -162,6 +193,7 @@ async def fetch_all[M: BaseModel](
162
193
  con: AsyncConnection | None = None,
163
194
  pool: ConnectionSource | None = None,
164
195
  cancel: asyncio.Event | None = None,
196
+ statement_timeout: float | None = None,
165
197
  ) -> list[M]: ...
166
198
 
167
199
 
@@ -174,6 +206,7 @@ async def fetch_all[T](
174
206
  con: AsyncConnection | None = None,
175
207
  pool: ConnectionSource | None = None,
176
208
  cancel: asyncio.Event | None = None,
209
+ statement_timeout: float | None = None,
177
210
  ) -> list[T]: ...
178
211
 
179
212
 
@@ -187,6 +220,7 @@ async def fetch_all(
187
220
  con: AsyncConnection | None = None,
188
221
  pool: ConnectionSource | None = None,
189
222
  cancel: asyncio.Event | None = None,
223
+ statement_timeout: float | None = None,
190
224
  ) -> list[dict[str, Any]]: ...
191
225
 
192
226
 
@@ -198,6 +232,7 @@ async def fetch_all[M: BaseModel](
198
232
  con: AsyncConnection | None = None,
199
233
  pool: ConnectionSource | None = None,
200
234
  cancel: asyncio.Event | None = None,
235
+ statement_timeout: float | None = None,
201
236
  ) -> list[M]: ...
202
237
 
203
238
 
@@ -209,6 +244,7 @@ async def fetch_all[T](
209
244
  con: AsyncConnection | None = None,
210
245
  pool: ConnectionSource | None = None,
211
246
  cancel: asyncio.Event | None = None,
247
+ statement_timeout: float | None = None,
212
248
  ) -> list[T]: ...
213
249
 
214
250
 
@@ -221,6 +257,7 @@ async def fetch_all(
221
257
  con: AsyncConnection | None = None,
222
258
  pool: ConnectionSource | None = None,
223
259
  cancel: asyncio.Event | None = None,
260
+ statement_timeout: float | None = None,
224
261
  ) -> list[dict[str, Any]]: ...
225
262
 
226
263
 
@@ -233,17 +270,25 @@ async def fetch_all(
233
270
  con: AsyncConnection | None = None,
234
271
  pool: ConnectionSource | None = None,
235
272
  cancel: asyncio.Event | None = None,
273
+ statement_timeout: float | None = None,
236
274
  ) -> list[Any]:
237
275
  """Run one query and return every row.
238
276
 
239
277
  Rows come back as plain dicts, or, with `model=` (a Pydantic model class), validated into
240
278
  that model; `row_mapper=` converts each dict row into any other shape. The two are exclusive.
241
279
 
280
+ Pass `statement_timeout` (seconds) to let Postgres abort the query after that long: `fetch_all` then
281
+ raises `psycopg.errors.QueryCanceled` ("canceling statement due to statement timeout"). It is applied
282
+ with `SET LOCAL` semantics, i.e. for this query's transaction only; a borrowed `con` in autocommit mode
283
+ gets a short transaction around the query, and any other borrowed `con` gets its previous setting back.
284
+ Without it, the connection's own `statement_timeout` (default: none) applies.
285
+
242
286
  Pass `con` to run on a connection you already hold (e.g. inside a transaction): it is
243
287
  borrowed, never committed, closed or returned to a pool. Without it, a connection is taken
244
288
  from `pool` or the pool registered via `set_default_pool()` and released afterwards.
245
289
 
246
- Set `cancel` (an `asyncio.Event`, e.g. when the HTTP client disconnects) to abort a running
290
+ Set `cancel` (an `asyncio.Event`, e.g. when the HTTP client disconnects; to stream a large result
291
+ from FastAPI see `pgdevkit.fastapi.PostgresJsonResponse`, which does that wiring itself) to abort a running
247
292
  query on the server: `fetch_all` then raises `psycopg.errors.QueryCanceled`. If the awaiting
248
293
  task is cancelled instead, the query is cancelled server-side too and `CancelledError`
249
294
  propagates, so the query never keeps running unattended. `cancel` aborts whatever statement is
@@ -261,7 +306,8 @@ async def fetch_all(
261
306
  raise TypeError("Pass either `model` or `row_mapper`, not both.")
262
307
  if isinstance(query, Template) and params is not None:
263
308
  raise TypeError("A t-string query carries its own values; don't pass `params` with it.")
264
- rows = await _fetch_dicts(_render(query), params, con, pool, cancel)
309
+ _check_statement_timeout(statement_timeout)
310
+ rows = await _fetch_dicts(_render(query), params, con, pool, cancel, statement_timeout)
265
311
  if model is not None:
266
312
  return [model.model_validate(row) for row in rows]
267
313
  if row_mapper is not None:
@@ -0,0 +1,3 @@
1
+ from .json_response import PostgresJsonResponse
2
+
3
+ __all__ = ["PostgresJsonResponse"]