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,172 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import json
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from plain.runtime import PLAIN_TEMP_PATH
11
+
12
+ from ..db import get_connection
13
+ from .clients import PostgresBackupClient
14
+
15
+
16
+ def get_git_branch() -> str | None:
17
+ """Get current git branch, or None if not in a git repo."""
18
+ try:
19
+ result = subprocess.run(
20
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
21
+ capture_output=True,
22
+ text=True,
23
+ check=True,
24
+ )
25
+ return result.stdout.strip()
26
+ except (subprocess.CalledProcessError, FileNotFoundError):
27
+ return None
28
+
29
+
30
+ def get_git_commit() -> str | None:
31
+ """Get current git commit (short hash), or None if not in a git repo."""
32
+ try:
33
+ result = subprocess.run(
34
+ ["git", "rev-parse", "--short", "HEAD"],
35
+ capture_output=True,
36
+ text=True,
37
+ check=True,
38
+ )
39
+ return result.stdout.strip()
40
+ except (subprocess.CalledProcessError, FileNotFoundError):
41
+ return None
42
+
43
+
44
+ class DatabaseBackups:
45
+ def __init__(self) -> None:
46
+ self.path = PLAIN_TEMP_PATH / "backups"
47
+
48
+ def find_backups(self) -> list[DatabaseBackup]:
49
+ if not self.path.exists():
50
+ return []
51
+
52
+ backups = []
53
+
54
+ for backup_dir in self.path.iterdir():
55
+ backup = DatabaseBackup(backup_dir.name, backups_path=self.path)
56
+ backups.append(backup)
57
+
58
+ # Sort backups by date
59
+ backups.sort(key=lambda x: x.updated_at(), reverse=True)
60
+
61
+ return backups
62
+
63
+ def create(
64
+ self, name: str, *, source: str = "manual", pg_dump: str = "pg_dump"
65
+ ) -> Path:
66
+ backup = DatabaseBackup(name, backups_path=self.path)
67
+ if backup.exists():
68
+ raise Exception(f"Backup {name} already exists")
69
+ backup_dir = backup.create(source=source, pg_dump=pg_dump)
70
+ try:
71
+ self.prune()
72
+ except Exception:
73
+ pass
74
+ return backup_dir
75
+
76
+ def prune(self) -> list[str]:
77
+ """Delete oldest backups on the current branch (or with no branch), keeping the most recent 20."""
78
+ keep = 20
79
+ current_branch = get_git_branch()
80
+ backups = self.find_backups() # sorted newest-first
81
+
82
+ # Only prune backups matching the current branch or with no branch metadata
83
+ prunable = [
84
+ b for b in backups if b.metadata.get("git_branch") in (current_branch, None)
85
+ ]
86
+
87
+ deleted = []
88
+ for backup in prunable[keep:]:
89
+ backup.delete()
90
+ deleted.append(backup.name)
91
+ return deleted
92
+
93
+ def restore(self, name: str, *, pg_restore: str = "pg_restore") -> None:
94
+ backup = DatabaseBackup(name, backups_path=self.path)
95
+ if not backup.exists():
96
+ raise Exception(f"Backup {name} not found")
97
+ backup.restore(pg_restore=pg_restore)
98
+
99
+ def delete(self, name: str) -> None:
100
+ backup = DatabaseBackup(name, backups_path=self.path)
101
+ if not backup.exists():
102
+ raise Exception(f"Backup {name} not found")
103
+ backup.delete()
104
+
105
+
106
+ class DatabaseBackup:
107
+ def __init__(self, name: str, *, backups_path: Path) -> None:
108
+ self.name = name
109
+ self.path = backups_path / name
110
+
111
+ if not self.name:
112
+ raise ValueError("Backup name is required")
113
+
114
+ def exists(self) -> bool:
115
+ return self.path.exists()
116
+
117
+ def create(self, *, source: str = "manual", pg_dump: str = "pg_dump") -> Path:
118
+ self.path.mkdir(parents=True, exist_ok=True)
119
+
120
+ backup_path = self.path / "default.backup"
121
+
122
+ PostgresBackupClient(get_connection()).create_backup(
123
+ backup_path,
124
+ pg_dump=pg_dump,
125
+ )
126
+
127
+ # Write metadata
128
+ metadata = {
129
+ "created_at": datetime.datetime.now(datetime.UTC).isoformat(),
130
+ "source": source,
131
+ "git_branch": get_git_branch(),
132
+ "git_commit": get_git_commit(),
133
+ }
134
+ metadata_path = self.path / "metadata.json"
135
+ with open(metadata_path, "w") as f:
136
+ json.dump(metadata, f, indent=2)
137
+
138
+ return self.path
139
+
140
+ def restore(self, *, pg_restore: str = "pg_restore") -> None:
141
+ backup_file = self.path / "default.backup"
142
+
143
+ PostgresBackupClient(get_connection()).restore_backup(
144
+ backup_file,
145
+ pg_restore=pg_restore,
146
+ )
147
+
148
+ @property
149
+ def metadata(self) -> dict[str, Any]:
150
+ """Read metadata from metadata.json, with fallback for old backups."""
151
+ metadata_path = self.path / "metadata.json"
152
+ if metadata_path.exists():
153
+ with open(metadata_path) as f:
154
+ return json.load(f)
155
+
156
+ return {
157
+ "created_at": None,
158
+ "source": None,
159
+ "git_branch": None,
160
+ "git_commit": None,
161
+ }
162
+
163
+ def delete(self) -> None:
164
+ backup_file = self.path / "default.backup"
165
+ backup_file.unlink(missing_ok=True)
166
+ metadata_file = self.path / "metadata.json"
167
+ metadata_file.unlink(missing_ok=True)
168
+ self.path.rmdir()
169
+
170
+ def updated_at(self) -> datetime.datetime:
171
+ mtime = os.path.getmtime(self.path)
172
+ return datetime.datetime.fromtimestamp(mtime)