plain.postgres 0.97.0__tar.gz → 0.98.0__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 (198) hide show
  1. plain_postgres-0.97.0/plain/postgres/README.md → plain_postgres-0.98.0/PKG-INFO +62 -25
  2. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/CHANGELOG.md +48 -0
  3. plain_postgres-0.97.0/PKG-INFO → plain_postgres-0.98.0/plain/postgres/README.md +48 -37
  4. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/__init__.py +3 -0
  5. plain_postgres-0.98.0/plain/postgres/adapters.py +41 -0
  6. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/core.py +16 -7
  7. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/config.py +4 -0
  8. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/connection.py +39 -303
  9. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/database_url.py +36 -9
  10. plain_postgres-0.98.0/plain/postgres/db.py +158 -0
  11. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/default_settings.py +7 -3
  12. plain_postgres-0.98.0/plain/postgres/middleware.py +37 -0
  13. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/otel.py +192 -11
  14. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/preflight.py +31 -1
  15. plain_postgres-0.98.0/plain/postgres/sources.py +221 -0
  16. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/test/database.py +47 -24
  17. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/test/pytest.py +26 -19
  18. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/transaction.py +8 -12
  19. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/utils.py +22 -8
  20. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/pyproject.toml +3 -3
  21. plain_postgres-0.98.0/tests/conftest.py +11 -0
  22. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_connection_isolation.py +16 -16
  23. plain_postgres-0.98.0/tests/test_connection_lifecycle.py +383 -0
  24. plain_postgres-0.98.0/tests/test_connection_pool.py +162 -0
  25. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_timeouts.py +2 -1
  26. plain_postgres-0.98.0/tests/test_executor_connection_hook.py +103 -0
  27. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_management_connection.py +3 -3
  28. plain_postgres-0.98.0/tests/test_otel_metrics.py +243 -0
  29. plain_postgres-0.98.0/tests/test_read_only_transactions.py +87 -0
  30. plain_postgres-0.97.0/plain/postgres/connections.py +0 -126
  31. plain_postgres-0.97.0/plain/postgres/db.py +0 -38
  32. plain_postgres-0.97.0/tests/test_connection_lifecycle.py +0 -354
  33. plain_postgres-0.97.0/tests/test_read_only_transactions.py +0 -116
  34. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/.gitignore +0 -0
  35. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/CLAUDE.md +0 -0
  36. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/LICENSE +0 -0
  37. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/README.md +0 -0
  38. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  39. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  40. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/aggregates.py +0 -0
  41. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/base.py +0 -0
  42. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/__init__.py +0 -0
  43. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/converge.py +0 -0
  44. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/decorators.py +0 -0
  45. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/diagnose.py +0 -0
  46. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/migrations.py +0 -0
  47. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/schema.py +0 -0
  48. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/cli/sync.py +0 -0
  49. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/constants.py +0 -0
  50. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/constraints.py +0 -0
  51. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/convergence/__init__.py +0 -0
  52. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/convergence/analysis.py +0 -0
  53. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/convergence/fixes.py +0 -0
  54. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/convergence/planning.py +0 -0
  55. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/ddl.py +0 -0
  56. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/deletion.py +0 -0
  57. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/dialect.py +0 -0
  58. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/entrypoints.py +0 -0
  59. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/enums.py +0 -0
  60. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/exceptions.py +0 -0
  61. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/expressions.py +0 -0
  62. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/__init__.py +0 -0
  63. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/base.py +0 -0
  64. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/binary.py +0 -0
  65. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/boolean.py +0 -0
  66. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/duration.py +0 -0
  67. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/encrypted.py +0 -0
  68. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/json.py +0 -0
  69. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/mixins.py +0 -0
  70. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/network.py +0 -0
  71. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/numeric.py +0 -0
  72. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/primary_key.py +0 -0
  73. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/related.py +0 -0
  74. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_descriptors.py +0 -0
  75. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_lookups.py +0 -0
  76. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_managers.py +0 -0
  77. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  78. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/reverse_related.py +0 -0
  79. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/temporal.py +0 -0
  80. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/text.py +0 -0
  81. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/timezones.py +0 -0
  82. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/fields/uuid.py +0 -0
  83. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/forms.py +0 -0
  84. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/__init__.py +0 -0
  85. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/comparison.py +0 -0
  86. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/datetime.py +0 -0
  87. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/math.py +0 -0
  88. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/mixins.py +0 -0
  89. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/random.py +0 -0
  90. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/text.py +0 -0
  91. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/uuid.py +0 -0
  92. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/functions/window.py +0 -0
  93. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/indexes.py +0 -0
  94. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/introspection/__init__.py +0 -0
  95. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/introspection/health.py +0 -0
  96. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/introspection/schema.py +0 -0
  97. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/lookups.py +0 -0
  98. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/meta.py +0 -0
  99. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/__init__.py +0 -0
  100. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/autodetector.py +0 -0
  101. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/exceptions.py +0 -0
  102. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/executor.py +0 -0
  103. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/graph.py +0 -0
  104. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/loader.py +0 -0
  105. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/migration.py +0 -0
  106. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  107. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/base.py +0 -0
  108. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/fields.py +0 -0
  109. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/models.py +0 -0
  110. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/special.py +0 -0
  111. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/optimizer.py +0 -0
  112. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/questioner.py +0 -0
  113. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/recorder.py +0 -0
  114. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/serializer.py +0 -0
  115. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/state.py +0 -0
  116. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/utils.py +0 -0
  117. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/migrations/writer.py +0 -0
  118. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/options.py +0 -0
  119. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/query.py +0 -0
  120. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/query_utils.py +0 -0
  121. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/registry.py +0 -0
  122. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/schema.py +0 -0
  123. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/__init__.py +0 -0
  124. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/compiler.py +0 -0
  125. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/constants.py +0 -0
  126. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/datastructures.py +0 -0
  127. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/query.py +0 -0
  128. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/sql/where.py +0 -0
  129. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/test/__init__.py +0 -0
  130. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/types.py +0 -0
  131. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/plain/postgres/types.pyi +0 -0
  132. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/forms.py +0 -0
  133. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  134. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  135. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  136. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  137. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  138. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  139. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  140. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  141. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  142. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  143. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  144. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  145. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  146. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  147. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  148. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  149. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  150. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/__init__.py +0 -0
  151. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/__init__.py +0 -0
  152. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/constraints.py +0 -0
  153. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/defaults.py +0 -0
  154. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/delete.py +0 -0
  155. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/encrypted.py +0 -0
  156. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/forms.py +0 -0
  157. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/indexes.py +0 -0
  158. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/iteration.py +0 -0
  159. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/mixins.py +0 -0
  160. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/nullability.py +0 -0
  161. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/querysets.py +0 -0
  162. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/relationships.py +0 -0
  163. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/trees.py +0 -0
  164. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/models/unregistered.py +0 -0
  165. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/urls.py +0 -0
  166. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/examples/views.py +0 -0
  167. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/settings.py +0 -0
  168. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/app/urls.py +0 -0
  169. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/conftest_convergence.py +0 -0
  170. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_autodetector_not_null_errors.py +0 -0
  171. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_autodetector_type_change.py +0 -0
  172. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence.py +0 -0
  173. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_constraints.py +0 -0
  174. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_defaults.py +0 -0
  175. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_fk.py +0 -0
  176. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_indexes.py +0 -0
  177. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_convergence_nullability.py +0 -0
  178. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_database_url.py +0 -0
  179. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_db_expression_defaults.py +0 -0
  180. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_delete_behaviors.py +0 -0
  181. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_encrypted_fields.py +0 -0
  182. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_exceptions.py +0 -0
  183. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_field_defaults.py +0 -0
  184. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_functions_uuid.py +0 -0
  185. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_introspection.py +0 -0
  186. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_iterator.py +0 -0
  187. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_literal_default_persistence.py +0 -0
  188. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_m2m.py +0 -0
  189. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_manager_assignment.py +0 -0
  190. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_migration_executor.py +0 -0
  191. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_mixins.py +0 -0
  192. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_modelform_roundtrip.py +0 -0
  193. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_no_callable_defaults.py +0 -0
  194. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_random_string_field.py +0 -0
  195. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_raw_query.py +0 -0
  196. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_related.py +0 -0
  197. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_schema_normalize_type.py +0 -0
  198. {plain_postgres-0.97.0 → plain_postgres-0.98.0}/tests/test_schema_timeouts.py +0 -0
@@ -1,9 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.postgres
3
+ Version: 0.98.0
4
+ Summary: Model your data and store it in a database.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: plain<1.0.0,>=0.134.0
10
+ Requires-Dist: psycopg-pool>=3.2
11
+ Requires-Dist: psycopg>=3.2
12
+ Requires-Dist: sqlparse>=0.3.1
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # plain.postgres
2
16
 
3
17
  **Model your data and store it in a database.**
4
18
 
5
19
  - [Overview](#overview)
6
20
  - [Database connection](#database-connection)
21
+ - [Middleware](#middleware)
22
+ - [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
7
23
  - [Querying](#querying)
8
24
  - [Schema management](#schema-management)
9
25
  - [Syncing](#syncing)
@@ -99,6 +115,24 @@ To explicitly disable the database (e.g. during Docker builds where no database
99
115
  PLAIN_POSTGRES_URL=none
100
116
  ```
101
117
 
118
+ ### Middleware
119
+
120
+ Connections are checked out lazily on first use and returned to the pool when the HTTP request finishes. That's handled by [`DatabaseConnectionMiddleware`](./middleware.py#DatabaseConnectionMiddleware) — add it to `MIDDLEWARE` once you install `plain.postgres`:
121
+
122
+ ```python
123
+ # app/settings.py
124
+ MIDDLEWARE = [
125
+ "plain.postgres.DatabaseConnectionMiddleware",
126
+ # ...other middleware
127
+ ]
128
+ ```
129
+
130
+ Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly at the end.
131
+
132
+ For `StreamingResponse` / `AsyncStreamingResponse`, the connection is returned after the body is fully drained (not when the view returns), so generators that lazily query the database — for example `Model.query.iterator()` or raw cursor loops — keep their cursor alive until the last chunk is sent.
133
+
134
+ Without the middleware, connections keep living on their thread until something explicitly calls `plain.postgres.db.return_database_connection()` (or the process exits). That's fine for short-lived scripts but wastes a connection per thread in long-running servers.
135
+
102
136
  ### Bypassing a connection pooler for management operations
103
137
 
104
138
  Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
@@ -128,14 +162,6 @@ with use_management_connection():
128
162
 
129
163
  You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
130
164
 
131
- **PostgreSQL is the only supported database.** You need to install a PostgreSQL driver separately — [psycopg](https://www.psycopg.org/) is recommended:
132
-
133
- ```bash
134
- uv add psycopg[binary] # Pre-built wheels, easiest for local development
135
- # or
136
- uv add psycopg[c] # Compiled against your system's libpq, recommended for production
137
- ```
138
-
139
165
  ## Querying
140
166
 
141
167
  Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
@@ -443,32 +469,34 @@ with transaction.atomic():
443
469
  safe_operation() # This still runs in the outer transaction
444
470
  ```
445
471
 
446
- ### Read-only connections
472
+ ### Read-only transactions
447
473
 
448
- Enforce read-only mode on the current database connection using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
474
+ Run a block of code in a read-only transaction using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
449
475
 
450
476
  ```python
451
- from plain.postgres.connections import read_only
477
+ from plain.postgres.db import read_only
452
478
 
453
479
  with read_only():
454
480
  users = User.query.all() # reads work
455
481
  User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
456
482
  ```
457
483
 
458
- This works with both autocommit queries and explicit `atomic()` blocks.
484
+ `read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
459
485
 
460
- For sticky read-only mode (e.g., a shell session), use `set_read_only()` on the connection directly:
486
+ Because it opens its own transaction, `read_only()` cannot be entered inside an existing `atomic()` block doing so raises `TransactionManagementError`.
461
487
 
462
- ```python
463
- from plain.postgres.db import get_connection
488
+ Because the whole block is one transaction, catching a database error inside `read_only()` and trying to keep reading will fail — the transaction is aborted and any further query raises `TransactionManagementError`. Wrap the write in a nested `atomic()` savepoint if you need to recover and continue:
464
489
 
465
- conn = get_connection()
466
- conn.set_read_only(True) # all subsequent queries are read-only
467
- conn.set_read_only(False) # back to normal
490
+ ```python
491
+ with read_only():
492
+ try:
493
+ with atomic():
494
+ User.query.create(name="x") # raises, savepoint rolls back
495
+ except psycopg.errors.ReadOnlySqlTransaction:
496
+ pass
497
+ User.query.count() # still works — outer txn is healthy
468
498
  ```
469
499
 
470
- Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
471
-
472
500
  ## Schema management
473
501
 
474
502
  Schema changes fall into three categories, each with a different author and apply model:
@@ -1237,8 +1265,10 @@ The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL`
1237
1265
  | ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
1238
1266
  | `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
1239
1267
  | `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
1240
- | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1241
- | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1268
+ | `POSTGRES_POOL_MIN_SIZE` | `int` | `4` | `PLAIN_POSTGRES_POOL_MIN_SIZE` |
1269
+ | `POSTGRES_POOL_MAX_SIZE` | `int` | `20` | `PLAIN_POSTGRES_POOL_MAX_SIZE` |
1270
+ | `POSTGRES_POOL_MAX_LIFETIME` | `float` | `3600.0` | `PLAIN_POSTGRES_POOL_MAX_LIFETIME` |
1271
+ | `POSTGRES_POOL_TIMEOUT` | `float` | `30.0` | `PLAIN_POSTGRES_POOL_TIMEOUT` |
1242
1272
  | `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
1243
1273
  | `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
1244
1274
  | `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
@@ -1282,13 +1312,15 @@ Currently, Plain supports a single database connection per application. For appl
1282
1312
 
1283
1313
  ## Installation
1284
1314
 
1285
- Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/):
1315
+ Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/). You must also pick a `psycopg` implementation — `plain.postgres` depends on `psycopg` but does not pick one for you, so installing `plain.postgres` alone will not be able to connect.
1286
1316
 
1287
1317
  ```bash
1288
- uv add plain.postgres psycopg[binary]
1318
+ uv add plain.postgres psycopg[binary] # Pre-built wheels, easiest for local development
1319
+ # or
1320
+ uv add plain.postgres psycopg[c] # Compiled against your system's libpq, recommended for production
1289
1321
  ```
1290
1322
 
1291
- Then add to your `INSTALLED_PACKAGES`:
1323
+ Then add to your `INSTALLED_PACKAGES` and register [`DatabaseConnectionMiddleware`](#middleware) so pooled connections are returned at the end of each request:
1292
1324
 
1293
1325
  ```python
1294
1326
  # app/settings.py
@@ -1296,4 +1328,9 @@ INSTALLED_PACKAGES = [
1296
1328
  ...
1297
1329
  "plain.postgres",
1298
1330
  ]
1331
+
1332
+ MIDDLEWARE = [
1333
+ "plain.postgres.DatabaseConnectionMiddleware",
1334
+ ...
1335
+ ]
1299
1336
  ```
@@ -1,5 +1,53 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.98.0](https://github.com/dropseed/plain/releases/plain-postgres@0.98.0) (2026-04-22)
4
+
5
+ ### What's changed
6
+
7
+ - **Pool-backed connections via `psycopg_pool.ConnectionPool`.** A new `sources` abstraction routes `DatabaseConnection` through either a long-lived `PoolSource` (runtime) or a `DirectSource` (management / one-shot). Each request checks a connection out of the pool on first use and returns it when the HTTP request finishes. `psycopg>=3.2` and `psycopg-pool>=3.2` are now declared as hard dependencies. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
8
+ - **New `DatabaseConnectionMiddleware` (required).** Add `"plain.postgres.DatabaseConnectionMiddleware"` to `MIDDLEWARE` — it's what returns the pooled connection at the end of each request. For `StreamingResponse` / `AsyncStreamingResponse` the connection is returned after the body fully drains, so generators that lazily query the database (e.g. `Model.query.iterator()`) keep their cursor alive until the last chunk is sent. A new `postgres.middleware_installed` preflight check errors if the middleware is missing. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
9
+ - **Connection settings replaced with pool settings.** `POSTGRES_CONN_MAX_AGE` and `POSTGRES_CONN_HEALTH_CHECKS` are gone. Tune the pool with `POSTGRES_POOL_MIN_SIZE` (default `4`), `POSTGRES_POOL_MAX_SIZE` (default `20`), `POSTGRES_POOL_MAX_LIFETIME` seconds (default `3600.0`), and `POSTGRES_POOL_TIMEOUT` seconds (default `30.0`). Each is also available as a `PLAIN_POSTGRES_POOL_*` environment variable. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
10
+ - **`plain.postgres.connections` module removed.** `get_connection`, `has_connection`, `use_management_connection`, and `read_only` now live in `plain.postgres.db` (the underscore-less counterpart). ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
11
+ - **`read_only()` is now pgbouncer-safe.** It opens a single `BEGIN READ ONLY` transaction for the block (previously a session-level `SET default_transaction_read_only = on`). Nested `atomic()` blocks become savepoints of the outer read-only transaction. Entering `read_only()` inside an existing `atomic()` block now raises `TransactionManagementError`. The old `DatabaseConnection.set_read_only()` method is removed. ([ebdec30](https://github.com/dropseed/plain/commit/ebdec30))
12
+ - **Added OTel pool + rowcount metrics and semconv polish.** Wires the `db.client.connection.*` metric family (count, max, idle.min/max, pending_requests, wait_time, use_time, timeouts) from the pool's stats and the acquire/release path, plus `db.client.response.returned_rows` for SELECT queries including streamed iterators. Query spans now carry `server.address` / `server.port` alongside `network.peer.*`, and the tracer/meter are tagged with the `plain.postgres` package version for `InstrumentationScope`. ([61278d5](https://github.com/dropseed/plain/commit/61278d5))
13
+ - **Moved `psql` CLI orchestration off `DatabaseConnection`.** New `postgres_cli_args` / `postgres_cli_env` helpers in `plain.postgres.database_url` build the arguments and environment for `psql`, `pg_dump`, etc.; `plain postgres shell` and the `plain-dev` backup client both use them. `DatabaseConnection.runshell()` and `executable_name` are gone. ([5b4a488](https://github.com/dropseed/plain/commit/5b4a488))
14
+ - **Removed dead connection-lifecycle plumbing.** `close_if_unusable_or_obsolete`, `close_if_health_check_failed`, `closed_in_transaction`, `is_usable`, `health_check_enabled`, `health_check_done`, `close_at`, `_maintenance_cursor`, and `DatabaseConnection.from_url` are gone — the pool handles recycling, health checks, and URL parsing. `close()` now validates there's no open atomic block instead of silently deferring. ([044e942](https://github.com/dropseed/plain/commit/044e942), [2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
15
+ - **Inlined `pg_version` and removed `temporary_connection()`.** The single caller now reads `connection.info.server_version` directly; `temporary_connection()` has no remaining users. ([319f6ac](https://github.com/dropseed/plain/commit/319f6ac))
16
+ - **`APIResult` shorthand returns moved out of `View`.** Any internal views that relied on dict/int shorthand now wrap their returns in `JsonResponse` / `Response(status_code=...)` to match plain 0.134.0's narrower `View` handler return type. ([1935f3f](https://github.com/dropseed/plain/commit/1935f3f))
17
+ - **Adapter registration extracted to `plain.postgres.adapters`.** `PlainRangeDumper` and `get_adapters_template()` moved out of `connection.py` into their own module.
18
+
19
+ ### Upgrade instructions
20
+
21
+ - Requires `plain>=0.134.0`.
22
+ - **Add the middleware** to `app/settings.py`:
23
+
24
+ ```python
25
+ MIDDLEWARE = [
26
+ "plain.postgres.DatabaseConnectionMiddleware",
27
+ # ...the rest of your middleware
28
+ ]
29
+ ```
30
+
31
+ Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly. Preflight will error if it's missing.
32
+
33
+ - **Replace `POSTGRES_CONN_MAX_AGE` / `POSTGRES_CONN_HEALTH_CHECKS`** with the pool settings (`POSTGRES_POOL_MIN_SIZE`, `POSTGRES_POOL_MAX_SIZE`, `POSTGRES_POOL_MAX_LIFETIME`, `POSTGRES_POOL_TIMEOUT`) or remove them to take the defaults.
34
+
35
+ - **Update imports from `plain.postgres.connections`** to `plain.postgres.db`:
36
+
37
+ ```python
38
+ # Before
39
+ from plain.postgres.connections import get_connection, read_only, use_management_connection
40
+
41
+ # After
42
+ from plain.postgres.db import get_connection, read_only, use_management_connection
43
+ ```
44
+
45
+ - **If you called `DatabaseConnection.set_read_only(True)`** for a sticky read-only session, switch to the `read_only()` context manager around the block you want read-only. If you need session-level enforcement outside a transaction, open a `DirectSource` connection yourself and issue `SET default_transaction_read_only = on` on it.
46
+
47
+ - **If you entered `read_only()` inside an `atomic()` block**, move `read_only()` to the outer position — it now owns the transaction. Nested `atomic()` blocks inside `read_only()` are fine (they become savepoints).
48
+
49
+ - **If you pinned `psycopg` via your own dependency**, make sure it's `>=3.2`, and add `psycopg-pool>=3.2` if you were installing psycopg without extras.
50
+
3
51
  ## [0.97.0](https://github.com/dropseed/plain/releases/plain-postgres@0.97.0) (2026-04-21)
4
52
 
5
53
  ### What's changed
@@ -1,21 +1,11 @@
1
- Metadata-Version: 2.4
2
- Name: plain.postgres
3
- Version: 0.97.0
4
- Summary: Model your data and store it in a database.
5
- Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
- License-Expression: BSD-3-Clause
7
- License-File: LICENSE
8
- Requires-Python: >=3.13
9
- Requires-Dist: plain<1.0.0,>=0.132.0
10
- Requires-Dist: sqlparse>=0.3.1
11
- Description-Content-Type: text/markdown
12
-
13
1
  # plain.postgres
14
2
 
15
3
  **Model your data and store it in a database.**
16
4
 
17
5
  - [Overview](#overview)
18
6
  - [Database connection](#database-connection)
7
+ - [Middleware](#middleware)
8
+ - [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
19
9
  - [Querying](#querying)
20
10
  - [Schema management](#schema-management)
21
11
  - [Syncing](#syncing)
@@ -111,6 +101,24 @@ To explicitly disable the database (e.g. during Docker builds where no database
111
101
  PLAIN_POSTGRES_URL=none
112
102
  ```
113
103
 
104
+ ### Middleware
105
+
106
+ Connections are checked out lazily on first use and returned to the pool when the HTTP request finishes. That's handled by [`DatabaseConnectionMiddleware`](./middleware.py#DatabaseConnectionMiddleware) — add it to `MIDDLEWARE` once you install `plain.postgres`:
107
+
108
+ ```python
109
+ # app/settings.py
110
+ MIDDLEWARE = [
111
+ "plain.postgres.DatabaseConnectionMiddleware",
112
+ # ...other middleware
113
+ ]
114
+ ```
115
+
116
+ Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly at the end.
117
+
118
+ For `StreamingResponse` / `AsyncStreamingResponse`, the connection is returned after the body is fully drained (not when the view returns), so generators that lazily query the database — for example `Model.query.iterator()` or raw cursor loops — keep their cursor alive until the last chunk is sent.
119
+
120
+ Without the middleware, connections keep living on their thread until something explicitly calls `plain.postgres.db.return_database_connection()` (or the process exits). That's fine for short-lived scripts but wastes a connection per thread in long-running servers.
121
+
114
122
  ### Bypassing a connection pooler for management operations
115
123
 
116
124
  Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
@@ -140,14 +148,6 @@ with use_management_connection():
140
148
 
141
149
  You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
142
150
 
143
- **PostgreSQL is the only supported database.** You need to install a PostgreSQL driver separately — [psycopg](https://www.psycopg.org/) is recommended:
144
-
145
- ```bash
146
- uv add psycopg[binary] # Pre-built wheels, easiest for local development
147
- # or
148
- uv add psycopg[c] # Compiled against your system's libpq, recommended for production
149
- ```
150
-
151
151
  ## Querying
152
152
 
153
153
  Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
@@ -455,32 +455,34 @@ with transaction.atomic():
455
455
  safe_operation() # This still runs in the outer transaction
456
456
  ```
457
457
 
458
- ### Read-only connections
458
+ ### Read-only transactions
459
459
 
460
- Enforce read-only mode on the current database connection using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
460
+ Run a block of code in a read-only transaction using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
461
461
 
462
462
  ```python
463
- from plain.postgres.connections import read_only
463
+ from plain.postgres.db import read_only
464
464
 
465
465
  with read_only():
466
466
  users = User.query.all() # reads work
467
467
  User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
468
468
  ```
469
469
 
470
- This works with both autocommit queries and explicit `atomic()` blocks.
470
+ `read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
471
471
 
472
- For sticky read-only mode (e.g., a shell session), use `set_read_only()` on the connection directly:
472
+ Because it opens its own transaction, `read_only()` cannot be entered inside an existing `atomic()` block doing so raises `TransactionManagementError`.
473
473
 
474
- ```python
475
- from plain.postgres.db import get_connection
474
+ Because the whole block is one transaction, catching a database error inside `read_only()` and trying to keep reading will fail — the transaction is aborted and any further query raises `TransactionManagementError`. Wrap the write in a nested `atomic()` savepoint if you need to recover and continue:
476
475
 
477
- conn = get_connection()
478
- conn.set_read_only(True) # all subsequent queries are read-only
479
- conn.set_read_only(False) # back to normal
476
+ ```python
477
+ with read_only():
478
+ try:
479
+ with atomic():
480
+ User.query.create(name="x") # raises, savepoint rolls back
481
+ except psycopg.errors.ReadOnlySqlTransaction:
482
+ pass
483
+ User.query.count() # still works — outer txn is healthy
480
484
  ```
481
485
 
482
- Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
483
-
484
486
  ## Schema management
485
487
 
486
488
  Schema changes fall into three categories, each with a different author and apply model:
@@ -1249,8 +1251,10 @@ The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL`
1249
1251
  | ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
1250
1252
  | `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
1251
1253
  | `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
1252
- | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1253
- | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1254
+ | `POSTGRES_POOL_MIN_SIZE` | `int` | `4` | `PLAIN_POSTGRES_POOL_MIN_SIZE` |
1255
+ | `POSTGRES_POOL_MAX_SIZE` | `int` | `20` | `PLAIN_POSTGRES_POOL_MAX_SIZE` |
1256
+ | `POSTGRES_POOL_MAX_LIFETIME` | `float` | `3600.0` | `PLAIN_POSTGRES_POOL_MAX_LIFETIME` |
1257
+ | `POSTGRES_POOL_TIMEOUT` | `float` | `30.0` | `PLAIN_POSTGRES_POOL_TIMEOUT` |
1254
1258
  | `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
1255
1259
  | `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
1256
1260
  | `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
@@ -1294,13 +1298,15 @@ Currently, Plain supports a single database connection per application. For appl
1294
1298
 
1295
1299
  ## Installation
1296
1300
 
1297
- Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/):
1301
+ Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/). You must also pick a `psycopg` implementation — `plain.postgres` depends on `psycopg` but does not pick one for you, so installing `plain.postgres` alone will not be able to connect.
1298
1302
 
1299
1303
  ```bash
1300
- uv add plain.postgres psycopg[binary]
1304
+ uv add plain.postgres psycopg[binary] # Pre-built wheels, easiest for local development
1305
+ # or
1306
+ uv add plain.postgres psycopg[c] # Compiled against your system's libpq, recommended for production
1301
1307
  ```
1302
1308
 
1303
- Then add to your `INSTALLED_PACKAGES`:
1309
+ Then add to your `INSTALLED_PACKAGES` and register [`DatabaseConnectionMiddleware`](#middleware) so pooled connections are returned at the end of each request:
1304
1310
 
1305
1311
  ```python
1306
1312
  # app/settings.py
@@ -1308,4 +1314,9 @@ INSTALLED_PACKAGES = [
1308
1314
  ...
1309
1315
  "plain.postgres",
1310
1316
  ]
1317
+
1318
+ MIDDLEWARE = [
1319
+ "plain.postgres.DatabaseConnectionMiddleware",
1320
+ ...
1321
+ ]
1311
1322
  ```
@@ -7,6 +7,7 @@ from . import (
7
7
  from .base import Model
8
8
  from .constraints import CheckConstraint, UniqueConstraint
9
9
  from .db import get_connection, use_management_connection
10
+ from .middleware import DatabaseConnectionMiddleware
10
11
  from .deletion import CASCADE, NO_ACTION, RESTRICT, SET_NULL
11
12
  from .expressions import F
12
13
  from .enums import TextChoices
@@ -105,6 +106,8 @@ __all__ = [
105
106
  # From db
106
107
  "get_connection",
107
108
  "use_management_connection",
109
+ # From middleware
110
+ "DatabaseConnectionMiddleware",
108
111
  # From registry
109
112
  "register_model",
110
113
  "models_registry",
@@ -0,0 +1,41 @@
1
+ """Psycopg adapter registration for Plain.
2
+
3
+ The `AdaptersMap` returned by `get_adapters_template()` is attached to every
4
+ psycopg connection we open (via `build_connection_params` in `sources.py`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import lru_cache
10
+ from typing import Any
11
+
12
+ from psycopg import adapt, adapters
13
+ from psycopg.abc import PyFormat
14
+ from psycopg.postgres import types as pg_types
15
+ from psycopg.types.range import BaseRangeDumper, Range, RangeDumper
16
+ from psycopg.types.string import TextLoader
17
+
18
+ TSRANGE_OID = pg_types["tsrange"].oid
19
+ TSTZRANGE_OID = pg_types["tstzrange"].oid
20
+
21
+
22
+ class PlainRangeDumper(RangeDumper):
23
+ """A Range dumper customized for Plain."""
24
+
25
+ def upgrade(self, obj: Range[Any], format: PyFormat) -> BaseRangeDumper:
26
+ dumper = super().upgrade(obj, format)
27
+ if dumper is not self and dumper.oid == TSRANGE_OID:
28
+ dumper.oid = TSTZRANGE_OID
29
+ return dumper
30
+
31
+
32
+ @lru_cache
33
+ def get_adapters_template() -> adapt.AdaptersMap:
34
+ ctx = adapt.AdaptersMap(adapters)
35
+ # No-op JSON loader to avoid psycopg3 round trips
36
+ ctx.register_loader("jsonb", TextLoader)
37
+ # Treat inet/cidr as text
38
+ ctx.register_loader("inet", TextLoader)
39
+ ctx.register_loader("cidr", TextLoader)
40
+ ctx.register_dumper(Range, PlainRangeDumper)
41
+ return ctx
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
4
+ import signal
3
5
  import subprocess
4
6
  import sys
5
7
  import time
@@ -10,6 +12,7 @@ import psycopg
10
12
 
11
13
  from plain.cli import register_cli
12
14
 
15
+ from ..database_url import postgres_cli_args, postgres_cli_env
13
16
  from ..db import get_connection
14
17
  from ..dialect import quote_name
15
18
  from .converge import converge
@@ -36,16 +39,20 @@ cli.add_command(sync)
36
39
  @database_management_command
37
40
  def shell(parameters: tuple[str, ...]) -> None:
38
41
  """Open an interactive database shell"""
39
- conn = get_connection()
42
+ config = get_connection().settings_dict
43
+ args = ["psql", *postgres_cli_args(config), *parameters, config["DATABASE"]]
44
+ env = {**os.environ, **postgres_cli_env(config)}
45
+ sigint_handler = signal.getsignal(signal.SIGINT)
40
46
  try:
41
- conn.runshell(list(parameters))
47
+ # Allow SIGINT to pass to psql to abort queries.
48
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
49
+ subprocess.run(args, env=env, check=True)
42
50
  except FileNotFoundError:
43
- # Note that we're assuming the FileNotFoundError relates to the
44
- # command missing. It could be raised for some other reason, in
45
- # which case this error message would be inaccurate. Still, this
46
- # message catches the common case.
51
+ # FileNotFoundError almost always means psql isn't installed or on
52
+ # PATH, but could be raised for other reasons — the message covers
53
+ # the common case.
47
54
  click.secho(
48
- f"You appear not to have the {conn.executable_name!r} program installed or on your path.",
55
+ "You appear not to have the 'psql' program installed or on your path.",
49
56
  fg="red",
50
57
  err=True,
51
58
  )
@@ -60,6 +67,8 @@ def shell(parameters: tuple[str, ...]) -> None:
60
67
  err=True,
61
68
  )
62
69
  sys.exit(e.returncode)
70
+ finally:
71
+ signal.signal(signal.SIGINT, sigint_handler)
63
72
 
64
73
 
65
74
  @cli.command("drop-unknown-tables")
@@ -4,7 +4,9 @@ from plain.packages import (
4
4
  register_config,
5
5
  )
6
6
 
7
+ from .otel import register_pool_observables
7
8
  from .registry import models_registry
9
+ from .sources import runtime_pool_source
8
10
 
9
11
 
10
12
  @register_config
@@ -16,3 +18,5 @@ class Config(PackageConfig):
16
18
  packages_registry.autodiscover_modules("models", include_app=False)
17
19
 
18
20
  models_registry.ready = True
21
+
22
+ register_pool_observables(runtime_pool_source)