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.
- plain/postgres/CHANGELOG.md +1028 -0
- plain/postgres/README.md +925 -0
- plain/postgres/__init__.py +120 -0
- plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
- plain/postgres/aggregates.py +236 -0
- plain/postgres/backups/__init__.py +0 -0
- plain/postgres/backups/cli.py +148 -0
- plain/postgres/backups/clients.py +94 -0
- plain/postgres/backups/core.py +172 -0
- plain/postgres/base.py +1415 -0
- plain/postgres/cli/__init__.py +3 -0
- plain/postgres/cli/db.py +142 -0
- plain/postgres/cli/migrations.py +1085 -0
- plain/postgres/config.py +18 -0
- plain/postgres/connection.py +1331 -0
- plain/postgres/connections.py +77 -0
- plain/postgres/constants.py +13 -0
- plain/postgres/constraints.py +495 -0
- plain/postgres/database_url.py +94 -0
- plain/postgres/db.py +59 -0
- plain/postgres/default_settings.py +38 -0
- plain/postgres/deletion.py +475 -0
- plain/postgres/dialect.py +640 -0
- plain/postgres/entrypoints.py +4 -0
- plain/postgres/enums.py +103 -0
- plain/postgres/exceptions.py +217 -0
- plain/postgres/expressions.py +1912 -0
- plain/postgres/fields/__init__.py +2118 -0
- plain/postgres/fields/encrypted.py +354 -0
- plain/postgres/fields/json.py +413 -0
- plain/postgres/fields/mixins.py +30 -0
- plain/postgres/fields/related.py +1192 -0
- plain/postgres/fields/related_descriptors.py +290 -0
- plain/postgres/fields/related_lookups.py +223 -0
- plain/postgres/fields/related_managers.py +661 -0
- plain/postgres/fields/reverse_descriptors.py +229 -0
- plain/postgres/fields/reverse_related.py +328 -0
- plain/postgres/fields/timezones.py +143 -0
- plain/postgres/forms.py +773 -0
- plain/postgres/functions/__init__.py +189 -0
- plain/postgres/functions/comparison.py +127 -0
- plain/postgres/functions/datetime.py +454 -0
- plain/postgres/functions/math.py +140 -0
- plain/postgres/functions/mixins.py +59 -0
- plain/postgres/functions/text.py +282 -0
- plain/postgres/functions/window.py +125 -0
- plain/postgres/indexes.py +286 -0
- plain/postgres/lookups.py +758 -0
- plain/postgres/meta.py +584 -0
- plain/postgres/migrations/__init__.py +53 -0
- plain/postgres/migrations/autodetector.py +1379 -0
- plain/postgres/migrations/exceptions.py +54 -0
- plain/postgres/migrations/executor.py +188 -0
- plain/postgres/migrations/graph.py +364 -0
- plain/postgres/migrations/loader.py +377 -0
- plain/postgres/migrations/migration.py +180 -0
- plain/postgres/migrations/operations/__init__.py +34 -0
- plain/postgres/migrations/operations/base.py +139 -0
- plain/postgres/migrations/operations/fields.py +373 -0
- plain/postgres/migrations/operations/models.py +798 -0
- plain/postgres/migrations/operations/special.py +184 -0
- plain/postgres/migrations/optimizer.py +74 -0
- plain/postgres/migrations/questioner.py +340 -0
- plain/postgres/migrations/recorder.py +119 -0
- plain/postgres/migrations/serializer.py +378 -0
- plain/postgres/migrations/state.py +882 -0
- plain/postgres/migrations/utils.py +147 -0
- plain/postgres/migrations/writer.py +302 -0
- plain/postgres/options.py +207 -0
- plain/postgres/otel.py +231 -0
- plain/postgres/preflight.py +336 -0
- plain/postgres/query.py +2242 -0
- plain/postgres/query_utils.py +456 -0
- plain/postgres/registry.py +217 -0
- plain/postgres/schema.py +1885 -0
- plain/postgres/sql/__init__.py +40 -0
- plain/postgres/sql/compiler.py +1869 -0
- plain/postgres/sql/constants.py +22 -0
- plain/postgres/sql/datastructures.py +222 -0
- plain/postgres/sql/query.py +2947 -0
- plain/postgres/sql/where.py +374 -0
- plain/postgres/test/__init__.py +0 -0
- plain/postgres/test/pytest.py +117 -0
- plain/postgres/test/utils.py +18 -0
- plain/postgres/transaction.py +222 -0
- plain/postgres/types.py +92 -0
- plain/postgres/types.pyi +751 -0
- plain/postgres/utils.py +345 -0
- plain_postgres-0.84.0.dist-info/METADATA +937 -0
- plain_postgres-0.84.0.dist-info/RECORD +93 -0
- plain_postgres-0.84.0.dist-info/WHEEL +4 -0
- plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
- 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)
|