plain.postgres 0.84.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. plain/postgres/CHANGELOG.md +1028 -0
  2. plain/postgres/README.md +925 -0
  3. plain/postgres/__init__.py +120 -0
  4. plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
  5. plain/postgres/aggregates.py +236 -0
  6. plain/postgres/backups/__init__.py +0 -0
  7. plain/postgres/backups/cli.py +148 -0
  8. plain/postgres/backups/clients.py +94 -0
  9. plain/postgres/backups/core.py +172 -0
  10. plain/postgres/base.py +1415 -0
  11. plain/postgres/cli/__init__.py +3 -0
  12. plain/postgres/cli/db.py +142 -0
  13. plain/postgres/cli/migrations.py +1085 -0
  14. plain/postgres/config.py +18 -0
  15. plain/postgres/connection.py +1331 -0
  16. plain/postgres/connections.py +77 -0
  17. plain/postgres/constants.py +13 -0
  18. plain/postgres/constraints.py +495 -0
  19. plain/postgres/database_url.py +94 -0
  20. plain/postgres/db.py +59 -0
  21. plain/postgres/default_settings.py +38 -0
  22. plain/postgres/deletion.py +475 -0
  23. plain/postgres/dialect.py +640 -0
  24. plain/postgres/entrypoints.py +4 -0
  25. plain/postgres/enums.py +103 -0
  26. plain/postgres/exceptions.py +217 -0
  27. plain/postgres/expressions.py +1912 -0
  28. plain/postgres/fields/__init__.py +2118 -0
  29. plain/postgres/fields/encrypted.py +354 -0
  30. plain/postgres/fields/json.py +413 -0
  31. plain/postgres/fields/mixins.py +30 -0
  32. plain/postgres/fields/related.py +1192 -0
  33. plain/postgres/fields/related_descriptors.py +290 -0
  34. plain/postgres/fields/related_lookups.py +223 -0
  35. plain/postgres/fields/related_managers.py +661 -0
  36. plain/postgres/fields/reverse_descriptors.py +229 -0
  37. plain/postgres/fields/reverse_related.py +328 -0
  38. plain/postgres/fields/timezones.py +143 -0
  39. plain/postgres/forms.py +773 -0
  40. plain/postgres/functions/__init__.py +189 -0
  41. plain/postgres/functions/comparison.py +127 -0
  42. plain/postgres/functions/datetime.py +454 -0
  43. plain/postgres/functions/math.py +140 -0
  44. plain/postgres/functions/mixins.py +59 -0
  45. plain/postgres/functions/text.py +282 -0
  46. plain/postgres/functions/window.py +125 -0
  47. plain/postgres/indexes.py +286 -0
  48. plain/postgres/lookups.py +758 -0
  49. plain/postgres/meta.py +584 -0
  50. plain/postgres/migrations/__init__.py +53 -0
  51. plain/postgres/migrations/autodetector.py +1379 -0
  52. plain/postgres/migrations/exceptions.py +54 -0
  53. plain/postgres/migrations/executor.py +188 -0
  54. plain/postgres/migrations/graph.py +364 -0
  55. plain/postgres/migrations/loader.py +377 -0
  56. plain/postgres/migrations/migration.py +180 -0
  57. plain/postgres/migrations/operations/__init__.py +34 -0
  58. plain/postgres/migrations/operations/base.py +139 -0
  59. plain/postgres/migrations/operations/fields.py +373 -0
  60. plain/postgres/migrations/operations/models.py +798 -0
  61. plain/postgres/migrations/operations/special.py +184 -0
  62. plain/postgres/migrations/optimizer.py +74 -0
  63. plain/postgres/migrations/questioner.py +340 -0
  64. plain/postgres/migrations/recorder.py +119 -0
  65. plain/postgres/migrations/serializer.py +378 -0
  66. plain/postgres/migrations/state.py +882 -0
  67. plain/postgres/migrations/utils.py +147 -0
  68. plain/postgres/migrations/writer.py +302 -0
  69. plain/postgres/options.py +207 -0
  70. plain/postgres/otel.py +231 -0
  71. plain/postgres/preflight.py +336 -0
  72. plain/postgres/query.py +2242 -0
  73. plain/postgres/query_utils.py +456 -0
  74. plain/postgres/registry.py +217 -0
  75. plain/postgres/schema.py +1885 -0
  76. plain/postgres/sql/__init__.py +40 -0
  77. plain/postgres/sql/compiler.py +1869 -0
  78. plain/postgres/sql/constants.py +22 -0
  79. plain/postgres/sql/datastructures.py +222 -0
  80. plain/postgres/sql/query.py +2947 -0
  81. plain/postgres/sql/where.py +374 -0
  82. plain/postgres/test/__init__.py +0 -0
  83. plain/postgres/test/pytest.py +117 -0
  84. plain/postgres/test/utils.py +18 -0
  85. plain/postgres/transaction.py +222 -0
  86. plain/postgres/types.py +92 -0
  87. plain/postgres/types.pyi +751 -0
  88. plain/postgres/utils.py +345 -0
  89. plain_postgres-0.84.0.dist-info/METADATA +937 -0
  90. plain_postgres-0.84.0.dist-info/RECORD +93 -0
  91. plain_postgres-0.84.0.dist-info/WHEEL +4 -0
  92. plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
  93. plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
@@ -0,0 +1,354 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from functools import cache
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ try:
9
+ from cryptography.fernet import Fernet, InvalidToken, MultiFernet
10
+ from cryptography.hazmat.primitives import hashes
11
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
12
+ except ImportError:
13
+ Fernet = None # type: ignore[assignment,misc]
14
+ InvalidToken = None # type: ignore[assignment,misc]
15
+ MultiFernet = None # type: ignore[assignment,misc]
16
+ hashes = None # type: ignore[assignment]
17
+ PBKDF2HMAC = None # type: ignore[assignment]
18
+
19
+ from plain import exceptions, preflight
20
+ from plain.runtime import settings
21
+ from plain.utils.encoding import force_bytes
22
+
23
+ from . import Field
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable
27
+
28
+ from plain.postgres.connection import DatabaseConnection
29
+ from plain.postgres.lookups import Lookup, Transform
30
+ from plain.preflight.results import PreflightResult
31
+
32
+ __all__ = [
33
+ "EncryptedTextField",
34
+ "EncryptedJSONField",
35
+ ]
36
+
37
+ # Fixed salt for key derivation — changing this would invalidate all encrypted data.
38
+ # This is not secret; it ensures the derived encryption key is distinct from
39
+ # keys derived for other purposes (e.g., signing) even from the same SECRET_KEY.
40
+ _KDF_SALT = b"plain.postgres.fields.encrypted"
41
+
42
+ # Prefix for encrypted values in the database.
43
+ # Makes encrypted data self-describing and distinguishable from plaintext.
44
+ _ENCRYPTED_PREFIX = "$fernet$"
45
+
46
+
47
+ def _derive_fernet_key(secret: str) -> bytes:
48
+ """Derive a Fernet-compatible key from an arbitrary secret string."""
49
+ if PBKDF2HMAC is None:
50
+ raise ImportError(
51
+ "The 'cryptography' package is required to use encrypted fields. "
52
+ "Install it with: pip install cryptography"
53
+ )
54
+ kdf = PBKDF2HMAC(
55
+ algorithm=hashes.SHA256(),
56
+ length=32,
57
+ salt=_KDF_SALT,
58
+ iterations=480_000,
59
+ )
60
+ return base64.urlsafe_b64encode(kdf.derive(force_bytes(secret)))
61
+
62
+
63
+ @cache
64
+ def _get_fernet(secret_key: str, fallbacks: tuple[str, ...]) -> MultiFernet:
65
+ """Build a MultiFernet from the given secret key and fallbacks.
66
+
67
+ The first key is used for encryption.
68
+ All keys are used for decryption, enabling key rotation.
69
+ Results are cached by (secret_key, fallbacks) so changing SECRET_KEY
70
+ (e.g. in tests) produces a new MultiFernet automatically.
71
+ """
72
+ keys = [_derive_fernet_key(secret_key)]
73
+ for fallback in fallbacks:
74
+ keys.append(_derive_fernet_key(fallback))
75
+ return MultiFernet([Fernet(k) for k in keys])
76
+
77
+
78
+ def _encrypt(value: str) -> str:
79
+ """Encrypt a string and return a self-describing database value."""
80
+ if value == "":
81
+ return value
82
+ f = _get_fernet(settings.SECRET_KEY, tuple(settings.SECRET_KEY_FALLBACKS))
83
+ token = f.encrypt(force_bytes(value))
84
+ return _ENCRYPTED_PREFIX + token.decode("ascii")
85
+
86
+
87
+ def _decrypt(value: str) -> str:
88
+ """Decrypt a self-describing database value back to a string.
89
+
90
+ Gracefully handles unencrypted values — if the value doesn't have
91
+ the encryption prefix, it's returned as-is. This supports gradual
92
+ migration from plaintext to encrypted fields.
93
+ """
94
+ if not value.startswith(_ENCRYPTED_PREFIX):
95
+ return value
96
+ token = value[len(_ENCRYPTED_PREFIX) :]
97
+ f = _get_fernet(settings.SECRET_KEY, tuple(settings.SECRET_KEY_FALLBACKS))
98
+ try:
99
+ return f.decrypt(token.encode("ascii")).decode("utf-8")
100
+ except InvalidToken:
101
+ raise ValueError(
102
+ "Could not decrypt field value. The SECRET_KEY (and SECRET_KEY_FALLBACKS) "
103
+ "may have changed since this data was encrypted."
104
+ )
105
+
106
+
107
+ # isnull is obviously needed. exact is required so that `filter(field=None)`
108
+ # works — the ORM resolves "exact" first and then rewrites None to isnull.
109
+ # Exact lookups on non-None values will silently return no results (since
110
+ # ciphertext is non-deterministic), but blocking exact entirely would break
111
+ # the None/isnull path.
112
+ _ALLOWED_LOOKUPS = {"isnull", "exact"}
113
+
114
+
115
+ class EncryptedFieldMixin:
116
+ """Shared behavior for all encrypted fields.
117
+
118
+ Blocks lookups (except isnull and exact) since encrypted values are non-deterministic.
119
+ Errors at preflight if the field is used in indexes or unique constraints.
120
+
121
+ Must be used with Field as a co-base class.
122
+ """
123
+
124
+ # Type hints for attributes provided by Field (the required co-base class)
125
+ name: str
126
+ model: Any
127
+
128
+ def get_lookup(self, lookup_name: str) -> type[Lookup] | None:
129
+ if lookup_name not in _ALLOWED_LOOKUPS:
130
+ return None
131
+ get_lookup = getattr(super(), "get_lookup")
132
+ return get_lookup(lookup_name)
133
+
134
+ def get_transform(
135
+ self, lookup_name: str
136
+ ) -> type[Transform] | Callable[..., Any] | None:
137
+ return None
138
+
139
+ def _check_encrypted_constraints(self) -> list[PreflightResult]:
140
+ errors: list[PreflightResult] = []
141
+ if not hasattr(self, "model"):
142
+ return errors
143
+
144
+ field_name = self.name
145
+
146
+ for constraint in self.model.model_options.constraints:
147
+ constraint_fields = getattr(constraint, "fields", ())
148
+ if field_name in constraint_fields:
149
+ errors.append(
150
+ preflight.PreflightResult(
151
+ fix=(
152
+ f"'{self.model.__name__}.{field_name}' is an encrypted field "
153
+ f"and cannot be used in constraint '{constraint.name}'. "
154
+ "Encrypted values are non-deterministic."
155
+ ),
156
+ obj=self,
157
+ id="fields.encrypted_in_constraint",
158
+ )
159
+ )
160
+
161
+ for index in self.model.model_options.indexes:
162
+ index_fields = getattr(index, "fields", ())
163
+ # Strip ordering prefix (e.g., "-field_name" for descending)
164
+ stripped_fields = [f.lstrip("-") for f in index_fields]
165
+ if field_name in stripped_fields:
166
+ errors.append(
167
+ preflight.PreflightResult(
168
+ fix=(
169
+ f"'{self.model.__name__}.{field_name}' is an encrypted field "
170
+ f"and cannot be used in index '{index.name}'. "
171
+ "Encrypted values are non-deterministic."
172
+ ),
173
+ obj=self,
174
+ id="fields.encrypted_in_index",
175
+ )
176
+ )
177
+
178
+ return errors
179
+
180
+
181
+ class EncryptedTextField(EncryptedFieldMixin, Field[str]):
182
+ """A text field that encrypts its value before storing in the database.
183
+
184
+ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key
185
+ derived from SECRET_KEY. The database column is always ``text`` regardless
186
+ of max_length, since ciphertext length is unpredictable.
187
+
188
+ max_length is enforced on the plaintext value (validation), not on the
189
+ ciphertext stored in the database.
190
+ """
191
+
192
+ description = "Encrypted text"
193
+
194
+ def get_internal_type(self) -> str:
195
+ # Always store as text — ciphertext is longer than plaintext
196
+ return "TextField"
197
+
198
+ def to_python(self, value: Any) -> str | None:
199
+ if isinstance(value, str) or value is None:
200
+ return value
201
+ return str(value)
202
+
203
+ def validate(self, value: Any, model_instance: Any) -> None:
204
+ super().validate(value, model_instance)
205
+ if (
206
+ self.max_length is not None
207
+ and value is not None
208
+ and len(value) > self.max_length
209
+ ):
210
+ raise exceptions.ValidationError(
211
+ f"Ensure this value has at most {self.max_length} characters (it has {len(value)}).",
212
+ code="max_length",
213
+ )
214
+
215
+ def get_prep_value(self, value: Any) -> Any:
216
+ value = super().get_prep_value(value)
217
+ if value is None:
218
+ return value
219
+ return self.to_python(value)
220
+
221
+ def get_db_prep_value(
222
+ self, value: Any, connection: DatabaseConnection, prepared: bool = False
223
+ ) -> Any:
224
+ value = super().get_db_prep_value(value, connection, prepared)
225
+ if value is None:
226
+ return value
227
+ return _encrypt(value)
228
+
229
+ def from_db_value(
230
+ self, value: Any, expression: Any, connection: DatabaseConnection
231
+ ) -> str | None:
232
+ if value is None:
233
+ return value
234
+ return _decrypt(value)
235
+
236
+ def deconstruct(self) -> tuple[str | None, str, list[Any], dict[str, Any]]:
237
+ name, path, args, kwargs = super().deconstruct()
238
+ # Override the path rewrite from Field.deconstruct() which would
239
+ # shorten "plain.postgres.fields.encrypted" to "plain.postgres.encrypted"
240
+ # (a module that doesn't exist).
241
+ path = f"{self.__class__.__module__}.{self.__class__.__qualname__}"
242
+ return name, path, args, kwargs
243
+
244
+ def preflight(self, **kwargs: Any) -> list[PreflightResult]:
245
+ errors = super().preflight(**kwargs)
246
+ errors.extend(self._check_encrypted_constraints())
247
+ return errors
248
+
249
+
250
+ class EncryptedJSONField(EncryptedFieldMixin, Field):
251
+ """A JSONField that encrypts its serialized value before storing in the database.
252
+
253
+ The JSON value is serialized to a string, encrypted, and stored as text.
254
+ On read, it's decrypted and deserialized back to a Python object.
255
+ """
256
+
257
+ empty_strings_allowed = False
258
+ description = "Encrypted JSON"
259
+ default_error_messages = {
260
+ "invalid": "Value must be valid JSON.",
261
+ }
262
+ _default_fix = ("dict", "{}")
263
+
264
+ def __init__(
265
+ self,
266
+ *,
267
+ encoder: type[json.JSONEncoder] | None = None,
268
+ decoder: type[json.JSONDecoder] | None = None,
269
+ **kwargs: Any,
270
+ ):
271
+ if encoder and not callable(encoder):
272
+ raise ValueError("The encoder parameter must be a callable object.")
273
+ if decoder and not callable(decoder):
274
+ raise ValueError("The decoder parameter must be a callable object.")
275
+ self.encoder = encoder
276
+ self.decoder = decoder
277
+ super().__init__(**kwargs)
278
+
279
+ def get_internal_type(self) -> str:
280
+ # Store as text, not jsonb — we're storing encrypted ciphertext
281
+ return "TextField"
282
+
283
+ def deconstruct(self) -> tuple[str | None, str, list[Any], dict[str, Any]]:
284
+ name, path, args, kwargs = super().deconstruct()
285
+ # Override the path rewrite from Field.deconstruct() which would
286
+ # shorten to a nonexistent module (same pattern as EncryptedTextField).
287
+ path = f"{self.__class__.__module__}.{self.__class__.__qualname__}"
288
+ if self.encoder is not None:
289
+ kwargs["encoder"] = self.encoder
290
+ if self.decoder is not None:
291
+ kwargs["decoder"] = self.decoder
292
+ return name, path, args, kwargs
293
+
294
+ def validate(self, value: Any, model_instance: Any) -> None:
295
+ super().validate(value, model_instance)
296
+ try:
297
+ json.dumps(value, cls=self.encoder)
298
+ except TypeError:
299
+ raise exceptions.ValidationError(
300
+ self.error_messages["invalid"],
301
+ code="invalid",
302
+ params={"value": value},
303
+ )
304
+
305
+ def get_db_prep_value(
306
+ self, value: Any, connection: DatabaseConnection, prepared: bool = False
307
+ ) -> Any:
308
+ value = super().get_db_prep_value(value, connection, prepared)
309
+ if value is None:
310
+ return value
311
+ json_str = json.dumps(value, cls=self.encoder)
312
+ return _encrypt(json_str)
313
+
314
+ def from_db_value(
315
+ self, value: Any, expression: Any, connection: DatabaseConnection
316
+ ) -> Any:
317
+ if value is None:
318
+ return value
319
+ decrypted = _decrypt(value)
320
+ try:
321
+ return json.loads(decrypted, cls=self.decoder)
322
+ except json.JSONDecodeError:
323
+ raise ValueError(
324
+ "Encrypted field contains data that is not valid JSON. "
325
+ "The stored value may be corrupt."
326
+ )
327
+
328
+ def _check_default(self) -> list[PreflightResult]:
329
+ if (
330
+ self.has_default()
331
+ and self.default is not None
332
+ and not callable(self.default)
333
+ ):
334
+ return [
335
+ preflight.PreflightResult(
336
+ fix=(
337
+ f"{self.__class__.__name__} default should be a callable instead of an instance "
338
+ "so that it's not shared between all field instances. "
339
+ "Use a callable instead, e.g., use `{}` instead of "
340
+ "`{}`.".format(*self._default_fix)
341
+ ),
342
+ obj=self,
343
+ id="fields.encrypted_mutable_default",
344
+ warning=True,
345
+ )
346
+ ]
347
+ else:
348
+ return []
349
+
350
+ def preflight(self, **kwargs: Any) -> list[PreflightResult]:
351
+ errors = super().preflight(**kwargs)
352
+ errors.extend(self._check_default())
353
+ errors.extend(self._check_encrypted_constraints())
354
+ return errors