plain.postgres 0.84.2__tar.gz → 0.85.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 (116) hide show
  1. plain_postgres-0.85.0/CLAUDE.md +11 -0
  2. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/PKG-INFO +58 -1
  3. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/CHANGELOG.md +14 -0
  4. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/README.md +57 -0
  5. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/__init__.py +1 -2
  6. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/base.py +7 -6
  7. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/cli/db.py +3 -2
  8. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/connection.py +37 -38
  9. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/connections.py +21 -0
  10. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/db.py +0 -22
  11. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/deletion.py +6 -5
  12. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/dialect.py +2 -2
  13. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/exceptions.py +1 -105
  14. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/expressions.py +3 -2
  15. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/exceptions.py +2 -2
  16. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/recorder.py +3 -2
  17. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/query.py +3 -2
  18. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/query_utils.py +3 -2
  19. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/query.py +4 -2
  20. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/transaction.py +10 -8
  21. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/utils.py +25 -40
  22. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/pyproject.toml +1 -1
  23. plain_postgres-0.85.0/tests/test_read_only_transactions.py +116 -0
  24. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/.gitignore +0 -0
  25. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/LICENSE +0 -0
  26. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/README.md +0 -0
  27. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  28. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/aggregates.py +0 -0
  29. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/backups/__init__.py +0 -0
  30. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/backups/cli.py +0 -0
  31. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/backups/clients.py +0 -0
  32. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/backups/core.py +0 -0
  33. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/cli/__init__.py +0 -0
  34. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/cli/migrations.py +0 -0
  35. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/config.py +0 -0
  36. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/constants.py +0 -0
  37. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/constraints.py +0 -0
  38. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/database_url.py +0 -0
  39. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/default_settings.py +0 -0
  40. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/entrypoints.py +0 -0
  41. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/enums.py +0 -0
  42. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/__init__.py +0 -0
  43. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/encrypted.py +0 -0
  44. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/json.py +0 -0
  45. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/mixins.py +0 -0
  46. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/related.py +0 -0
  47. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/related_descriptors.py +0 -0
  48. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/related_lookups.py +0 -0
  49. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/related_managers.py +0 -0
  50. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  51. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/reverse_related.py +0 -0
  52. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/fields/timezones.py +0 -0
  53. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/forms.py +0 -0
  54. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/__init__.py +0 -0
  55. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/comparison.py +0 -0
  56. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/datetime.py +0 -0
  57. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/math.py +0 -0
  58. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/mixins.py +0 -0
  59. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/text.py +0 -0
  60. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/functions/window.py +0 -0
  61. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/indexes.py +0 -0
  62. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/lookups.py +0 -0
  63. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/meta.py +0 -0
  64. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/__init__.py +0 -0
  65. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/autodetector.py +0 -0
  66. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/executor.py +0 -0
  67. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/graph.py +0 -0
  68. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/loader.py +0 -0
  69. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/migration.py +0 -0
  70. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  71. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/base.py +0 -0
  72. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/fields.py +0 -0
  73. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/models.py +0 -0
  74. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/operations/special.py +0 -0
  75. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/optimizer.py +0 -0
  76. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/questioner.py +0 -0
  77. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/serializer.py +0 -0
  78. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/state.py +0 -0
  79. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/utils.py +0 -0
  80. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/migrations/writer.py +0 -0
  81. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/options.py +0 -0
  82. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/otel.py +0 -0
  83. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/preflight.py +0 -0
  84. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/registry.py +0 -0
  85. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/schema.py +0 -0
  86. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/__init__.py +0 -0
  87. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/compiler.py +0 -0
  88. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/constants.py +0 -0
  89. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/datastructures.py +0 -0
  90. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/sql/where.py +0 -0
  91. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/test/__init__.py +0 -0
  92. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/test/pytest.py +0 -0
  93. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/test/utils.py +0 -0
  94. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/types.py +0 -0
  95. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/plain/postgres/types.pyi +0 -0
  96. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  97. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  98. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  99. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  100. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  101. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  102. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/migrations/__init__.py +0 -0
  103. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/examples/models.py +0 -0
  104. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/settings.py +0 -0
  105. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/app/urls.py +0 -0
  106. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_connection_isolation.py +0 -0
  107. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_connection_lifecycle.py +0 -0
  108. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_database_url.py +0 -0
  109. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_delete_behaviors.py +0 -0
  110. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_encrypted_fields.py +0 -0
  111. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_exceptions.py +0 -0
  112. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_iterator.py +0 -0
  113. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_manager_assignment.py +0 -0
  114. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_models.py +0 -0
  115. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_related_descriptors.py +0 -0
  116. {plain_postgres-0.84.2 → plain_postgres-0.85.0}/tests/test_related_manager_api.py +0 -0
@@ -0,0 +1,11 @@
1
+ # Developing plain-postgres
2
+
3
+ ## PostgreSQL
4
+
5
+ Minimum supported version is **PostgreSQL 16** (enforced by preflight check). Use PG 16+ features freely.
6
+
7
+ When implementing database features, fetch the official PostgreSQL docs to verify behavior — don't assume SQL semantics from memory. Check both the minimum version docs and the latest for newer features worth adopting: https://www.postgresql.org/docs/
8
+
9
+ ## psycopg3
10
+
11
+ The database driver is psycopg3 (`import psycopg`). Prefer its native APIs over hand-rolled abstractions.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.84.2
3
+ Version: 0.85.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -382,6 +382,63 @@ for row in HugeTable.query.iterator(chunk_size=2000):
382
382
  process(row)
383
383
  ```
384
384
 
385
+ ## Transactions
386
+
387
+ By default, each query runs in its own implicit transaction and is committed immediately (autocommit mode). When you need multiple queries to succeed or fail together — like creating a user and their profile — wrap them in an explicit transaction.
388
+
389
+ ### Atomic blocks
390
+
391
+ Wrap multiple queries in a transaction with `transaction.atomic()`:
392
+
393
+ ```python
394
+ from plain.postgres import transaction
395
+
396
+ with transaction.atomic():
397
+ user = User(email="test@example.com")
398
+ user.save()
399
+ Profile(user=user).save()
400
+ # Both saves commit together, or both roll back on error
401
+ ```
402
+
403
+ Nesting `atomic()` creates savepoints:
404
+
405
+ ```python
406
+ with transaction.atomic():
407
+ user.save()
408
+ try:
409
+ with transaction.atomic():
410
+ risky_operation() # If this fails...
411
+ except SomeError:
412
+ pass # ...only the inner block rolls back
413
+ safe_operation() # This still runs in the outer transaction
414
+ ```
415
+
416
+ ### Read-only connections
417
+
418
+ Enforce read-only mode on the current database connection using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
419
+
420
+ ```python
421
+ from plain.postgres.connections import read_only
422
+
423
+ with read_only():
424
+ users = User.query.all() # reads work
425
+ User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
426
+ ```
427
+
428
+ This works with both autocommit queries and explicit `atomic()` blocks.
429
+
430
+ For sticky read-only mode (e.g., a shell session), use `set_read_only()` on the connection directly:
431
+
432
+ ```python
433
+ from plain.postgres.db import get_connection
434
+
435
+ conn = get_connection()
436
+ conn.set_read_only(True) # all subsequent queries are read-only
437
+ conn.set_read_only(False) # back to normal
438
+ ```
439
+
440
+ Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
441
+
385
442
  ## Migrations
386
443
 
387
444
  Migrations track changes to your models and update the database schema accordingly. They are Python files stored in your app's `migrations/` directory.
@@ -1,5 +1,19 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.85.0](https://github.com/dropseed/plain/releases/plain-postgres@0.85.0) (2026-03-22)
4
+
5
+ ### What's changed
6
+
7
+ - Added read-only database connection support via `read_only()` context manager and `connection.set_read_only()` — enforces `SET default_transaction_read_only = ON` so any write attempt raises a database error ([69d23b04fde9](https://github.com/dropseed/plain/commit/69d23b04fde9))
8
+ - Removed PEP-249 exception mirror — `IntegrityError`, `OperationalError`, `ProgrammingError`, etc. are no longer re-exported from `plain.postgres`. Use `psycopg` exceptions directly (e.g. `psycopg.IntegrityError`) ([d4b170e60a2c](https://github.com/dropseed/plain/commit/d4b170e60a2c))
9
+ - Removed `DatabaseErrorWrapper` context manager — psycopg's native connection state handling replaces it ([015b04ce38e9](https://github.com/dropseed/plain/commit/015b04ce38e9))
10
+ - Added transaction and read-only connection documentation to README
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Replace any `from plain.postgres import IntegrityError` (or `OperationalError`, `ProgrammingError`, etc.) with `import psycopg` and use `psycopg.IntegrityError` directly.
15
+ - Replace any usage of `plain.postgres.db.DatabaseErrorWrapper` with standard `try/except` on psycopg exceptions.
16
+
3
17
  ## [0.84.2](https://github.com/dropseed/plain/releases/plain-postgres@0.84.2) (2026-03-20)
4
18
 
5
19
  ### What's changed
@@ -370,6 +370,63 @@ for row in HugeTable.query.iterator(chunk_size=2000):
370
370
  process(row)
371
371
  ```
372
372
 
373
+ ## Transactions
374
+
375
+ By default, each query runs in its own implicit transaction and is committed immediately (autocommit mode). When you need multiple queries to succeed or fail together — like creating a user and their profile — wrap them in an explicit transaction.
376
+
377
+ ### Atomic blocks
378
+
379
+ Wrap multiple queries in a transaction with `transaction.atomic()`:
380
+
381
+ ```python
382
+ from plain.postgres import transaction
383
+
384
+ with transaction.atomic():
385
+ user = User(email="test@example.com")
386
+ user.save()
387
+ Profile(user=user).save()
388
+ # Both saves commit together, or both roll back on error
389
+ ```
390
+
391
+ Nesting `atomic()` creates savepoints:
392
+
393
+ ```python
394
+ with transaction.atomic():
395
+ user.save()
396
+ try:
397
+ with transaction.atomic():
398
+ risky_operation() # If this fails...
399
+ except SomeError:
400
+ pass # ...only the inner block rolls back
401
+ safe_operation() # This still runs in the outer transaction
402
+ ```
403
+
404
+ ### Read-only connections
405
+
406
+ Enforce read-only mode on the current database connection using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
407
+
408
+ ```python
409
+ from plain.postgres.connections import read_only
410
+
411
+ with read_only():
412
+ users = User.query.all() # reads work
413
+ User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
414
+ ```
415
+
416
+ This works with both autocommit queries and explicit `atomic()` blocks.
417
+
418
+ For sticky read-only mode (e.g., a shell session), use `set_read_only()` on the connection directly:
419
+
420
+ ```python
421
+ from plain.postgres.db import get_connection
422
+
423
+ conn = get_connection()
424
+ conn.set_read_only(True) # all subsequent queries are read-only
425
+ conn.set_read_only(False) # back to normal
426
+ ```
427
+
428
+ Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
429
+
373
430
  ## Migrations
374
431
 
375
432
  Migrations track changes to your models and update the database schema accordingly. They are Python files stored in your app's `migrations/` directory.
@@ -6,7 +6,7 @@ from . import (
6
6
  # Imports that would create circular imports if sorted
7
7
  from .base import Model
8
8
  from .constraints import CheckConstraint, UniqueConstraint
9
- from .db import IntegrityError, get_connection
9
+ from .db import get_connection
10
10
  from .deletion import CASCADE, DO_NOTHING, PROTECT, RESTRICT, SET, SET_DEFAULT, SET_NULL
11
11
  from .enums import IntegerChoices, TextChoices
12
12
  from .fields import (
@@ -111,7 +111,6 @@ __all__ = [
111
111
  "ReverseManyToMany",
112
112
  # From db
113
113
  "get_connection",
114
- "IntegrityError",
115
114
  # From registry
116
115
  "register_model",
117
116
  "models_registry",
@@ -10,15 +10,14 @@ if TYPE_CHECKING:
10
10
  from plain.postgres.meta import Meta
11
11
  from plain.postgres.options import Options
12
12
 
13
+ import psycopg
14
+
13
15
  import plain.runtime
14
16
  from plain.exceptions import NON_FIELD_ERRORS, ValidationError
15
17
  from plain.postgres import models_registry, transaction, types
16
18
  from plain.postgres.constants import LOOKUP_SEP
17
19
  from plain.postgres.constraints import CheckConstraint, UniqueConstraint
18
- from plain.postgres.db import (
19
- PLAIN_VERSION_PICKLE_KEY,
20
- DatabaseError,
21
- )
20
+ from plain.postgres.db import PLAIN_VERSION_PICKLE_KEY
22
21
  from plain.postgres.deletion import Collector
23
22
  from plain.postgres.dialect import MAX_NAME_LENGTH
24
23
  from plain.postgres.exceptions import (
@@ -496,9 +495,11 @@ class Model(metaclass=ModelBase):
496
495
  base_qs, id_val, values, update_fields, forced_update
497
496
  )
498
497
  if force_update and not updated:
499
- raise DatabaseError("Forced update did not affect any rows.")
498
+ raise psycopg.DatabaseError("Forced update did not affect any rows.")
500
499
  if update_fields and not updated:
501
- raise DatabaseError("Save with update_fields did not affect any rows.")
500
+ raise psycopg.DatabaseError(
501
+ "Save with update_fields did not affect any rows."
502
+ )
502
503
  if not updated:
503
504
  fields = meta.local_concrete_fields
504
505
  if not id_set:
@@ -6,11 +6,12 @@ import time
6
6
  from collections import defaultdict
7
7
 
8
8
  import click
9
+ import psycopg
9
10
 
10
11
  from plain.cli import register_cli
11
12
 
12
13
  from ..backups.cli import cli as backups_cli
13
- from ..db import OperationalError, get_connection
14
+ from ..db import get_connection
14
15
  from ..dialect import quote_name
15
16
  from ..migrations.recorder import MIGRATION_TABLE_NAME
16
17
 
@@ -126,7 +127,7 @@ def wait() -> None:
126
127
 
127
128
  try:
128
129
  get_connection().ensure_connection()
129
- except OperationalError:
130
+ except psycopg.OperationalError:
130
131
  waiting_for = True
131
132
 
132
133
  if waiting_for:
@@ -28,9 +28,6 @@ from psycopg.types.string import TextLoader
28
28
  from plain.exceptions import ImproperlyConfigured
29
29
  from plain.logs import get_framework_logger
30
30
  from plain.postgres import utils
31
- from plain.postgres.db import (
32
- DatabaseErrorWrapper,
33
- )
34
31
  from plain.postgres.dialect import MAX_NAME_LENGTH, quote_name
35
32
  from plain.postgres.indexes import Index
36
33
  from plain.postgres.schema import DatabaseSchemaEditor
@@ -203,7 +200,6 @@ class DatabaseConnection:
203
200
  # Connection termination related attributes.
204
201
  self.close_at: float | None = None
205
202
  self.closed_in_transaction: bool = False
206
- self.errors_occurred: bool = False
207
203
  self.health_check_enabled: bool = False
208
204
  self.health_check_done: bool = False
209
205
 
@@ -383,8 +379,30 @@ class DatabaseConnection:
383
379
  def _set_autocommit(self, autocommit: bool) -> None:
384
380
  """Backend-specific implementation to enable or disable autocommit."""
385
381
  assert self.connection is not None
386
- with self.wrap_database_errors:
387
- self.connection.autocommit = autocommit
382
+ self.connection.autocommit = autocommit
383
+
384
+ def set_read_only(self, read_only: bool) -> None:
385
+ """Set read-only mode on this connection.
386
+
387
+ When enabled, all subsequent transactions will be read-only —
388
+ any INSERT/UPDATE/DELETE/DDL will raise a database error.
389
+ This applies to both explicit transactions and autocommit queries.
390
+ Persists until changed or the connection is closed.
391
+
392
+ Must be called outside a transaction — the setting only takes
393
+ effect on the next transaction that starts.
394
+ """
395
+ if self.in_atomic_block:
396
+ raise TransactionManagementError(
397
+ "set_read_only() cannot be called inside a transaction. "
398
+ "Call it before entering an atomic block."
399
+ )
400
+ self.ensure_connection()
401
+ assert self.connection is not None
402
+ if read_only:
403
+ self.connection.execute("SET default_transaction_read_only = on")
404
+ else:
405
+ self.connection.execute("SET default_transaction_read_only = off")
388
406
 
389
407
  def check_constraints(self, table_names: list[str] | None = None) -> None:
390
408
  """
@@ -449,7 +467,6 @@ class DatabaseConnection:
449
467
  max_age = self.settings_dict["CONN_MAX_AGE"]
450
468
  self.close_at = None if max_age is None else time.monotonic() + max_age
451
469
  self.closed_in_transaction = False
452
- self.errors_occurred = False
453
470
  # New connections are healthy.
454
471
  self.health_check_done = True
455
472
  # Establish the connection
@@ -463,8 +480,7 @@ class DatabaseConnection:
463
480
  def ensure_connection(self) -> None:
464
481
  """Guarantee that a connection to the database is established."""
465
482
  if self.connection is None:
466
- with self.wrap_database_errors:
467
- self.connect()
483
+ self.connect()
468
484
 
469
485
  # ##### PEP-249 connection method wrappers #####
470
486
 
@@ -481,23 +497,21 @@ class DatabaseConnection:
481
497
  def _cursor(self) -> utils.CursorWrapper:
482
498
  self.close_if_health_check_failed()
483
499
  self.ensure_connection()
484
- with self.wrap_database_errors:
485
- return self._prepare_cursor(self.create_cursor())
500
+ return self._prepare_cursor(self.create_cursor())
486
501
 
487
502
  def _commit(self) -> None:
488
503
  if self.connection is not None:
489
- with debug_transaction(self, "COMMIT"), self.wrap_database_errors:
504
+ with debug_transaction(self, "COMMIT"):
490
505
  return self.connection.commit()
491
506
 
492
507
  def _rollback(self) -> None:
493
508
  if self.connection is not None:
494
- with debug_transaction(self, "ROLLBACK"), self.wrap_database_errors:
509
+ with debug_transaction(self, "ROLLBACK"):
495
510
  return self.connection.rollback()
496
511
 
497
512
  def _close(self) -> None:
498
513
  if self.connection is not None:
499
- with self.wrap_database_errors:
500
- return self.connection.close()
514
+ return self.connection.close()
501
515
 
502
516
  # ##### Generic wrappers for PEP-249 connection methods #####
503
517
 
@@ -509,16 +523,12 @@ class DatabaseConnection:
509
523
  """Commit a transaction and reset the dirty flag."""
510
524
  self.validate_no_atomic_block()
511
525
  self._commit()
512
- # A successful commit means that the database connection works.
513
- self.errors_occurred = False
514
526
  self.run_commit_hooks_on_set_autocommit_on = True
515
527
 
516
528
  def rollback(self) -> None:
517
529
  """Roll back a transaction and reset the dirty flag."""
518
530
  self.validate_no_atomic_block()
519
531
  self._rollback()
520
- # A successful rollback means that the database connection works.
521
- self.errors_occurred = False
522
532
  self.needs_rollback = False
523
533
  self.run_on_commit = []
524
534
 
@@ -684,8 +694,8 @@ class DatabaseConnection:
684
694
 
685
695
  def close_if_unusable_or_obsolete(self) -> None:
686
696
  """
687
- Close the current connection if unrecoverable errors have occurred
688
- or if it outlived its maximum age.
697
+ Close the current connection if it's broken, improperly restored,
698
+ or has outlived its maximum age.
689
699
  """
690
700
  if self.connection is not None:
691
701
  self.health_check_done = False
@@ -695,15 +705,12 @@ class DatabaseConnection:
695
705
  self.close()
696
706
  return
697
707
 
698
- # If an exception other than DataError or IntegrityError occurred
699
- # since the last commit / rollback, check if the connection works.
700
- if self.errors_occurred:
701
- if self.is_usable():
702
- self.errors_occurred = False
703
- self.health_check_done = True
704
- else:
705
- self.close()
706
- return
708
+ # If psycopg detected the connection is dead (e.g. server
709
+ # terminated the backend), close our wrapper so the next
710
+ # request gets a fresh connection.
711
+ if self.connection.closed:
712
+ self.close()
713
+ return
707
714
 
708
715
  if self.close_at is not None and time.monotonic() >= self.close_at:
709
716
  self.close()
@@ -711,14 +718,6 @@ class DatabaseConnection:
711
718
 
712
719
  # ##### Miscellaneous #####
713
720
 
714
- @cached_property
715
- def wrap_database_errors(self) -> DatabaseErrorWrapper:
716
- """
717
- Context manager and decorator that re-throws backend-specific database
718
- exceptions using Plain's common wrappers.
719
- """
720
- return DatabaseErrorWrapper(self)
721
-
722
721
  def make_cursor(self, cursor: Any) -> utils.CursorWrapper:
723
722
  """Create a cursor without debug logging."""
724
723
  return utils.CursorWrapper(cursor, self)
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Generator
4
+ from contextlib import contextmanager
3
5
  from contextvars import ContextVar
4
6
  from typing import TYPE_CHECKING, Any, TypedDict
5
7
 
@@ -75,3 +77,22 @@ def get_connection() -> DatabaseConnection:
75
77
  def has_connection() -> bool:
76
78
  """Check if a database connection exists in the current context."""
77
79
  return _db_conn.get() is not None
80
+
81
+
82
+ @contextmanager
83
+ def read_only() -> Generator[None]:
84
+ """Set the current database connection to read-only for the duration of this block.
85
+
86
+ Any INSERT/UPDATE/DELETE/DDL will raise a database error. This applies
87
+ to all queries in the block — both explicit transactions and implicit
88
+ autocommit queries.
89
+ """
90
+ conn = get_connection()
91
+ conn.set_read_only(True)
92
+ try:
93
+ yield
94
+ finally:
95
+ try:
96
+ conn.set_read_only(False)
97
+ except Exception:
98
+ pass
@@ -5,18 +5,6 @@ from typing import Any
5
5
  from plain import signals
6
6
 
7
7
  from .connections import get_connection, has_connection
8
- from .exceptions import (
9
- DatabaseError,
10
- DatabaseErrorWrapper,
11
- DataError,
12
- Error,
13
- IntegrityError,
14
- InterfaceError,
15
- InternalError,
16
- NotSupportedError,
17
- OperationalError,
18
- ProgrammingError,
19
- )
20
8
 
21
9
  PLAIN_VERSION_PICKLE_KEY = "_plain_version"
22
10
 
@@ -45,15 +33,5 @@ __all__ = [
45
33
  "get_connection",
46
34
  "has_connection",
47
35
  "PLAIN_VERSION_PICKLE_KEY",
48
- "Error",
49
- "InterfaceError",
50
- "DatabaseError",
51
- "DataError",
52
- "OperationalError",
53
- "IntegrityError",
54
- "InternalError",
55
- "ProgrammingError",
56
- "NotSupportedError",
57
- "DatabaseErrorWrapper",
58
36
  "close_old_connections",
59
37
  ]
@@ -7,11 +7,12 @@ from itertools import chain
7
7
  from operator import attrgetter, or_
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
+ import psycopg
11
+
10
12
  from plain.postgres import (
11
13
  query_utils,
12
14
  transaction,
13
15
  )
14
- from plain.postgres.db import IntegrityError
15
16
  from plain.postgres.meta import Meta
16
17
  from plain.postgres.query import QuerySet
17
18
  from plain.postgres.sql import DeleteQuery, UpdateQuery
@@ -28,16 +29,16 @@ if TYPE_CHECKING:
28
29
  _LAZY_ON_DELETE: set[Callable[..., Any]] = set()
29
30
 
30
31
 
31
- class ProtectedError(IntegrityError):
32
+ class ProtectedError(psycopg.IntegrityError):
32
33
  def __init__(self, msg: str, protected_objects: Iterable[Any]) -> None:
33
34
  self.protected_objects = protected_objects
34
- super().__init__(msg, protected_objects)
35
+ super().__init__(msg)
35
36
 
36
37
 
37
- class RestrictedError(IntegrityError):
38
+ class RestrictedError(psycopg.IntegrityError):
38
39
  def __init__(self, msg: str, restricted_objects: Iterable[Any]) -> None:
39
40
  self.restricted_objects = restricted_objects
40
- super().__init__(msg, restricted_objects)
41
+ super().__init__(msg)
41
42
 
42
43
 
43
44
  def CASCADE(collector: Collector, field: RelatedField, sub_objs: Any) -> None:
@@ -13,11 +13,11 @@ from collections.abc import Callable, Iterable
13
13
  from functools import lru_cache, partial
14
14
  from typing import TYPE_CHECKING, Any
15
15
 
16
+ import psycopg
16
17
  from psycopg.types import numeric
17
18
  from psycopg.types.json import Jsonb
18
19
 
19
20
  from plain.postgres.constants import OnConflict
20
- from plain.postgres.db import NotSupportedError
21
21
  from plain.postgres.utils import split_tzname_delta
22
22
  from plain.utils import timezone
23
23
  from plain.utils.regex_helper import _lazy_re_compile
@@ -583,7 +583,7 @@ def window_frame_range_start_end(
583
583
  start_, end_ = window_frame_rows_start_end(start, end)
584
584
  # PostgreSQL only supports UNBOUNDED with PRECEDING/FOLLOWING
585
585
  if (start and start < 0) or (end and end > 0):
586
- raise NotSupportedError(
586
+ raise psycopg.NotSupportedError(
587
587
  "PostgreSQL only supports UNBOUNDED together with PRECEDING and FOLLOWING."
588
588
  )
589
589
  return start_, end_
@@ -1,14 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Callable
4
- from typing import TYPE_CHECKING, Any, TypeVar, cast
5
-
6
- import psycopg
7
-
8
- if TYPE_CHECKING:
9
- from plain.postgres.connection import DatabaseConnection
10
-
11
- F = TypeVar("F", bound=Callable[..., Any])
3
+ from typing import Any, cast
12
4
 
13
5
  # MARK: Database Query Exceptions
14
6
 
@@ -119,99 +111,3 @@ class MultipleObjectsReturnedDescriptor:
119
111
 
120
112
  def __set__(self, instance: Any, value: Any) -> None:
121
113
  raise AttributeError("Cannot set MultipleObjectsReturned")
122
-
123
-
124
- # MARK: Database Exceptions (PEP-249)
125
-
126
-
127
- class Error(Exception):
128
- pass
129
-
130
-
131
- class InterfaceError(Error):
132
- pass
133
-
134
-
135
- class DatabaseError(Error):
136
- pass
137
-
138
-
139
- class DataError(DatabaseError):
140
- pass
141
-
142
-
143
- class OperationalError(DatabaseError):
144
- pass
145
-
146
-
147
- class IntegrityError(DatabaseError):
148
- pass
149
-
150
-
151
- class InternalError(DatabaseError):
152
- pass
153
-
154
-
155
- class ProgrammingError(DatabaseError):
156
- pass
157
-
158
-
159
- class NotSupportedError(DatabaseError):
160
- pass
161
-
162
-
163
- class DatabaseErrorWrapper:
164
- """
165
- Context manager and decorator that reraises backend-specific database
166
- exceptions using Plain's common wrappers.
167
- """
168
-
169
- def __init__(self, wrapper: DatabaseConnection) -> None:
170
- """
171
- wrapper is a database wrapper.
172
-
173
- It must have a Database attribute defining PEP-249 exceptions.
174
- """
175
- self.wrapper = wrapper
176
-
177
- def __enter__(self) -> None:
178
- pass
179
-
180
- def __exit__(
181
- self,
182
- exc_type: type[BaseException] | None,
183
- exc_value: BaseException | None,
184
- traceback: Any,
185
- ) -> None:
186
- if exc_type is None:
187
- return
188
- for plain_exc_type in (
189
- DataError,
190
- OperationalError,
191
- IntegrityError,
192
- InternalError,
193
- ProgrammingError,
194
- NotSupportedError,
195
- DatabaseError,
196
- InterfaceError,
197
- Error,
198
- ):
199
- db_exc_type = getattr(psycopg, plain_exc_type.__name__)
200
- if issubclass(exc_type, db_exc_type):
201
- plain_exc_value = (
202
- plain_exc_type(*exc_value.args) if exc_value else plain_exc_type()
203
- )
204
- # Only set the 'errors_occurred' flag for errors that may make
205
- # the connection unusable.
206
- if plain_exc_type not in (DataError, IntegrityError):
207
- self.wrapper.errors_occurred = True
208
- raise plain_exc_value.with_traceback(traceback) from exc_value
209
-
210
- def __call__(self, func: F) -> F:
211
- # Note that we are intentionally not using @wraps here for performance
212
- # reasons. Refs #21109.
213
- def inner(*args: Any, **kwargs: Any) -> Any:
214
- with self:
215
- return func(*args, **kwargs)
216
-
217
- return cast(F, inner)
@@ -11,9 +11,10 @@ from types import NoneType
11
11
  from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
12
12
  from uuid import UUID
13
13
 
14
+ import psycopg
15
+
14
16
  from plain.postgres import fields
15
17
  from plain.postgres.constants import LOOKUP_SEP
16
- from plain.postgres.db import NotSupportedError
17
18
  from plain.postgres.dialect import (
18
19
  CURRENT_ROW,
19
20
  FOLLOWING,
@@ -877,7 +878,7 @@ class ResolvedOuterRef(F):
877
878
  def resolve_expression(self, *args: Any, **kwargs: Any) -> Any:
878
879
  col = super().resolve_expression(*args, **kwargs)
879
880
  if col.contains_over_clause:
880
- raise NotSupportedError(
881
+ raise psycopg.NotSupportedError(
881
882
  f"Referencing outer query window expression is not supported: "
882
883
  f"{self.name}."
883
884
  )
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
- from plain.postgres.db import DatabaseError
5
+ import psycopg
6
6
 
7
7
 
8
8
  class AmbiguityError(Exception):
@@ -50,5 +50,5 @@ class NodeNotFoundError(LookupError):
50
50
  return f"NodeNotFoundError({self.node!r})"
51
51
 
52
52
 
53
- class MigrationSchemaMissing(DatabaseError):
53
+ class MigrationSchemaMissing(psycopg.DatabaseError):
54
54
  pass