postgresledger 0.0.1__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.
@@ -0,0 +1,82 @@
1
+ """Settings: postgresledger.toml (the project, the same for every database) and
2
+ the one database this run works on, from DATABASE_* variables (.env or the environment)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+ import tomllib
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from dotenv import load_dotenv
13
+
14
+ CONFIG_NAME = "postgresledger.toml"
15
+ DEFAULT_META_SCHEMA = "postgresledger"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Connection:
20
+ """How to reach one database. Another database on the same server:
21
+ dataclasses.replace(connection, database="other")."""
22
+
23
+ host: str
24
+ port: int
25
+ user: str
26
+ password: str
27
+ database: str
28
+ sslmode: str = "prefer"
29
+
30
+ def environment(self) -> dict[str, str]:
31
+ """The PG* variables psql and pgschema read to connect to this database.
32
+
33
+ All of them are always set, so a value left in the shell never applies to this database.
34
+ """
35
+ return {
36
+ "PGHOST": self.host,
37
+ "PGPORT": str(self.port),
38
+ "PGUSER": self.user,
39
+ "PGPASSWORD": self.password,
40
+ "PGDATABASE": self.database,
41
+ "PGSSLMODE": self.sslmode,
42
+ }
43
+
44
+ @property
45
+ def label(self) -> str:
46
+ return f"{self.database}@{self.host}:{self.port}"
47
+
48
+
49
+ class Config:
50
+ def __init__(self, path: Path = Path(CONFIG_NAME), env_file: Path | None = None):
51
+ if not path.is_file():
52
+ sys.exit(f"no {path} here - run `pgl init` first")
53
+ self.path = path.resolve()
54
+ self.root = self.path.parent # paths in the config file are relative to it
55
+ project = tomllib.loads(self.path.read_text()).get("project", {})
56
+ self.schema = project.get("schema", "public") # the schema pgschema manages
57
+ self.schema_file = self.root / project.get("schema_file", "database/schema/schema.sql")
58
+ self.migrations_dir = self.root / project.get("migrations_dir", "database/migrations")
59
+ self.snapshots_dir = self.root / project.get("snapshots_dir", "database/snapshots")
60
+ self.meta_schema = project.get("meta_schema", DEFAULT_META_SCHEMA)
61
+ self.meta_table = f"{self.meta_schema}.schema_migrations"
62
+ self.env_file = env_file or Path(os.environ.get("PGL_ENV_FILE") or self.root / ".env")
63
+
64
+ # the one database this run works on; None when DATABASE_NAME is not set.
65
+ # Each DATABASE_* variable comes from the environment if set there, else from .env
66
+ # (load_dotenv never overrides a variable that is already set).
67
+ load_dotenv(self.env_file)
68
+ self.target: Connection | None = None
69
+ if database := os.environ.get("DATABASE_NAME"):
70
+ self.target = Connection(
71
+ host=os.environ.get("DATABASE_HOST", "localhost"),
72
+ port=int(os.environ.get("DATABASE_PORT", "5432")),
73
+ user=os.environ.get("DATABASE_USER", "postgres"),
74
+ password=os.environ.get("DATABASE_PASSWORD", ""),
75
+ database=database,
76
+ sslmode=os.environ.get("DATABASE_SSLMODE", "prefer"),
77
+ )
78
+
79
+
80
+ def relative(path: Path) -> str:
81
+ """The path as the user would type it from the current folder."""
82
+ return os.path.relpath(path, Path.cwd())
postgresledger/core.py ADDED
@@ -0,0 +1,288 @@
1
+ """psql / pgschema calls, migration files, the scratch databases and the tracking table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from dataclasses import dataclass, replace
14
+ from pathlib import Path
15
+
16
+ from . import pgschema
17
+ from .config import Config, Connection, relative
18
+
19
+ MIGRATION_RE = re.compile(r"^(\d{4})_([a-z0-9_]+)\.sql$")
20
+ ADVISORY_LOCK_KEY = 727274 # same key everywhere -> two applies on one database never run in parallel
21
+ # databases on the scratch server only
22
+ SHADOW_DB = "shadow"
23
+ PLAN_DB = "plan"
24
+
25
+
26
+ def run(command: list[str], connection: Connection, *, stdin: str | None = None) -> subprocess.CompletedProcess:
27
+ """Run psql or pgschema against `connection`; if it fails, show its output and stop pgl."""
28
+ # the shell environment (PATH, HOME, ...) plus the PG* variables of this connection
29
+ environment = {**os.environ, **connection.environment()}
30
+ program = Path(command[0]).name
31
+ try:
32
+ return subprocess.run(command, input=stdin, env=environment, text=True, capture_output=True, check=True)
33
+ except subprocess.CalledProcessError as error:
34
+ # the program ran and failed: its own output says why
35
+ program_output = (error.stdout + error.stderr).strip()
36
+ sys.exit(f"{program} failed on {connection.label}:\n{program_output}")
37
+ except OSError as error:
38
+ # the program could not run at all, e.g. psql is not installed
39
+ sys.exit(f"could not run {program}: {error}")
40
+
41
+
42
+ def psql(connection: Connection, sql: str) -> str:
43
+ """Run SQL (one statement or a whole script) on `connection` and return psql's output."""
44
+ command = [
45
+ "psql",
46
+ "--no-psqlrc", # ignore the user's ~/.psqlrc
47
+ "--quiet",
48
+ "--set", "ON_ERROR_STOP=1", # stop at the first error, exit code != 0
49
+ "--no-align", # plain values, separated by |
50
+ "--tuples-only", # no headers, no row count
51
+ ]
52
+ return run(command, connection, stdin=sql).stdout.strip()
53
+
54
+
55
+ @dataclass
56
+ class Migration:
57
+ version: str
58
+ name: str
59
+ path: Path
60
+
61
+ @property
62
+ def id(self) -> str:
63
+ return f"{self.version}_{self.name}"
64
+
65
+
66
+ def list_migrations(cfg: Config) -> list[Migration]:
67
+ cfg.migrations_dir.mkdir(parents=True, exist_ok=True)
68
+ out = []
69
+ for p in sorted(cfg.migrations_dir.iterdir()):
70
+ if m := MIGRATION_RE.match(p.name):
71
+ out.append(Migration(m.group(1), m.group(2), p))
72
+ versions = [m.version for m in out]
73
+ if len(versions) != len(set(versions)):
74
+ sys.exit("two migrations share the same version number")
75
+ return out
76
+
77
+
78
+ # Tracking table (on the target database)
79
+
80
+
81
+ def ensure_meta(cfg: Config, connection: Connection) -> None:
82
+ psql(connection, f"""
83
+ CREATE SCHEMA IF NOT EXISTS {cfg.meta_schema};
84
+ CREATE TABLE IF NOT EXISTS {cfg.meta_table} (
85
+ version text PRIMARY KEY,
86
+ name text NOT NULL,
87
+ checksum text NOT NULL,
88
+ mode text NOT NULL CHECK (mode IN ('applied', 'baseline', 'captured')),
89
+ applied_at timestamptz NOT NULL DEFAULT now(),
90
+ applied_by text NOT NULL DEFAULT current_user
91
+ );""")
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class AppliedMigration:
96
+ """One row of the tracking table: a migration this database has."""
97
+
98
+ version: str
99
+ name: str
100
+ checksum: str
101
+ mode: str # applied, baseline or captured
102
+ applied_at: str
103
+ applied_by: str # the database user that ran it
104
+
105
+
106
+ def applied(cfg: Config, connection: Connection) -> dict[str, AppliedMigration]:
107
+ """The migrations this database has, by version."""
108
+ ensure_meta(cfg, connection)
109
+ # Postgres returns the rows as one JSON array of objects, so every value is read by its column name
110
+ rows_as_json = psql(connection, f"""
111
+ SELECT coalesce(json_agg(json_build_object(
112
+ 'version', version,
113
+ 'name', name,
114
+ 'checksum', checksum,
115
+ 'mode', mode,
116
+ 'applied_at', to_char(applied_at, 'YYYY-MM-DD HH24:MI:SS'),
117
+ 'applied_by', applied_by
118
+ ) ORDER BY version), '[]')
119
+ FROM {cfg.meta_table}""")
120
+ applied_by_version = {}
121
+ for row in json.loads(rows_as_json):
122
+ applied_by_version[row["version"]] = AppliedMigration(**row)
123
+ return applied_by_version
124
+
125
+
126
+ def record_migration_sql(cfg: Config, migration: Migration, mode: str) -> str:
127
+ """The INSERT that records `migration` in the tracking table, as text.
128
+
129
+ Returned instead of run, so it can go into the same transaction as the migration itself.
130
+ applied_at and applied_by are filled in by the table's defaults.
131
+ """
132
+ checksum = hashlib.sha256(migration.path.read_bytes()).hexdigest()
133
+ # none of the values can contain a quote: version is digits, name is [a-z0-9_],
134
+ # checksum is hex and mode is one of applied / baseline / captured
135
+ return (f"INSERT INTO {cfg.meta_table} (version, name, checksum, mode) VALUES "
136
+ f"('{migration.version}', '{migration.name}', '{checksum}', '{mode}');")
137
+
138
+
139
+ def apply_file(cfg: Config, connection: Connection, migration: Migration, mode: str = "applied") -> None:
140
+ """Apply one migration and record it in the tracking table - atomically whenever possible."""
141
+ migration_sql = migration.path.read_text()
142
+ record_sql = record_migration_sql(cfg, migration, mode)
143
+ # CREATE INDEX CONCURRENTLY and the like cannot run inside a transaction
144
+ runs_in_transaction = "CONCURRENTLY" not in migration_sql.upper()
145
+ if runs_in_transaction:
146
+ script = (f"BEGIN;\nSELECT pg_advisory_xact_lock({ADVISORY_LOCK_KEY});\n"
147
+ f"\\i {migration.path}\n{record_sql}\nCOMMIT;\n")
148
+ else:
149
+ script = f"\\i {migration.path}\n{record_sql}\n"
150
+ psql(connection, script)
151
+
152
+
153
+ def check_checksums(connection: Connection, migrations: list[Migration],
154
+ applied_by_version: dict[str, AppliedMigration]) -> None:
155
+ """Stop when an applied migration file was edited since; warn about applied migrations missing here."""
156
+ for migration in migrations:
157
+ applied_migration = applied_by_version.get(migration.version)
158
+ if applied_migration is None:
159
+ continue
160
+ checksum = hashlib.sha256(migration.path.read_bytes()).hexdigest()
161
+ if applied_migration.checksum != checksum:
162
+ sys.exit(f"{migration.path.name} was edited after it was applied to {connection.label} (checksum mismatch)")
163
+ known_versions = {migration.version for migration in migrations}
164
+ for applied_migration in applied_by_version.values():
165
+ if applied_migration.version not in known_versions:
166
+ print(f"! {connection.label} has migration {applied_migration.version}_{applied_migration.name} "
167
+ "that is not in the repository")
168
+
169
+
170
+ def require_up_to_date(cfg: Config, connection: Connection) -> None:
171
+ applied_by_version = applied(cfg, connection)
172
+ pending = [migration.id for migration in list_migrations(cfg) if migration.version not in applied_by_version]
173
+ if pending:
174
+ sys.exit(f"{connection.label} is not up to date (pending: {', '.join(pending)}) - run `pgl apply` first")
175
+
176
+
177
+ # pgschema and the scratch server
178
+
179
+
180
+ def reset_database(scratch: Connection, database: str) -> Connection:
181
+ """Drop and recreate `database` on the scratch server and return a connection to it."""
182
+ psql(scratch, f'DROP DATABASE IF EXISTS "{database}" WITH (FORCE)')
183
+ psql(scratch, f'CREATE DATABASE "{database}"')
184
+ return replace(scratch, database=database)
185
+
186
+
187
+ def build_shadow(cfg: Config, scratch: Connection, migrations: list[Migration]) -> Connection:
188
+ """Scratch database = empty database + `migrations`: the state those migrations describe."""
189
+ shadow = reset_database(scratch, SHADOW_DB)
190
+ ensure_meta(cfg, shadow)
191
+ for migration in migrations:
192
+ apply_file(cfg, shadow, migration)
193
+ return shadow
194
+
195
+
196
+ def plan_arguments(scratch: Connection) -> list[str]:
197
+ """pgschema validates the desired state in a database on the scratch server (not its own embedded one)."""
198
+ if not psql(scratch, f"SELECT 1 FROM pg_database WHERE datname = '{PLAN_DB}'"):
199
+ psql(scratch, f'CREATE DATABASE "{PLAN_DB}"')
200
+ return [
201
+ "--plan-host", scratch.host,
202
+ "--plan-port", str(scratch.port),
203
+ "--plan-user", scratch.user,
204
+ "--plan-db", PLAN_DB,
205
+ "--plan-sslmode", "disable",
206
+ ]
207
+
208
+
209
+ def pgschema_dump(cfg: Config, connection: Connection, output_file: Path) -> None:
210
+ output_file.parent.mkdir(parents=True, exist_ok=True)
211
+ dump = run([pgschema.locate(), "dump", "--schema", cfg.schema], connection).stdout
212
+ output_file.write_text(dump)
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class Plan:
217
+ """What pgschema would change, in two forms. Both are empty when there is nothing to change."""
218
+
219
+ sql: str # the statements, e.g. for a migration file
220
+ human: str # the same changes as a readable list for the terminal
221
+
222
+
223
+ def pgschema_plan(cfg: Config, connection: Connection, desired: Path, scratch: Connection) -> Plan:
224
+ """The plan that turns `connection` into the state from `desired`."""
225
+ with tempfile.TemporaryDirectory() as temporary_dir:
226
+ sql_file = Path(temporary_dir) / "plan.sql"
227
+ human_file = Path(temporary_dir) / "plan.txt"
228
+ command = [
229
+ pgschema.locate(), "plan",
230
+ "--schema", cfg.schema,
231
+ "--file", str(desired),
232
+ "--output-sql", str(sql_file),
233
+ "--output-human", str(human_file),
234
+ "--no-color",
235
+ *plan_arguments(scratch),
236
+ ]
237
+ run(command, connection)
238
+ # pgschema writes no file when there is nothing to change
239
+ sql = sql_file.read_text() if sql_file.exists() else ""
240
+ human = human_file.read_text() if human_file.exists() else ""
241
+ return Plan(sql=sql.strip(), human=human.strip())
242
+
243
+
244
+ # Generating migrations
245
+
246
+
247
+ def is_valid_migration_name(name: str) -> bool:
248
+ """Lowercase letters, digits and _ only, so the name is safe in a file name and in SQL."""
249
+ allowed_characters = set("abcdefghijklmnopqrstuvwxyz0123456789_")
250
+ return name != "" and set(name) <= allowed_characters
251
+
252
+
253
+ def write_migration(cfg: Config, name: str, sql: str, note: str) -> Migration:
254
+ if not is_valid_migration_name(name):
255
+ sys.exit("migration name: lowercase letters, digits and _ only")
256
+ existing = list_migrations(cfg)
257
+ next_number = int(existing[-1].version) + 1 if existing else 1
258
+ version = f"{next_number:04d}"
259
+ path = cfg.migrations_dir / f"{version}_{name}.sql"
260
+ generated_at = dt.datetime.now().isoformat(timespec="seconds")
261
+ header = (f"-- migration: {version}_{name}\n"
262
+ f"-- generated: {generated_at} by postgresledger/pgschema\n"
263
+ f"-- {note}\n\n")
264
+ path.write_text(header + sql + "\n")
265
+ return Migration(version, name, path)
266
+
267
+
268
+ def generate_migration(cfg: Config, scratch: Connection, name: str, note: str) -> Migration | None:
269
+ """schema.sql vs (every migration applied) -> new migration, then check that it reproduces schema.sql."""
270
+ existing = list_migrations(cfg)
271
+ print(f"- building the state of {len(existing)} existing migrations (scratch Postgres)")
272
+ shadow = build_shadow(cfg, scratch, existing)
273
+ plan = pgschema_plan(cfg, shadow, cfg.schema_file, scratch)
274
+ if not plan.sql:
275
+ print("no changes compared to the existing migrations")
276
+ return None
277
+ migration = write_migration(cfg, name, plan.sql, note)
278
+ print(plan.human)
279
+ print(f"- verifying {migration.path.name}")
280
+ apply_file(cfg, shadow, migration)
281
+ # with the new migration applied, nothing should be left to change
282
+ leftover = pgschema_plan(cfg, shadow, cfg.schema_file, scratch)
283
+ if leftover.sql:
284
+ print(leftover.human)
285
+ migration.path.unlink()
286
+ sys.exit("after applying the migration the result still differs from schema.sql - migration deleted")
287
+ print(f"✔ created {relative(migration.path)}")
288
+ return migration
@@ -0,0 +1,112 @@
1
+ """The pgschema binary: pinned version, downloaded once per machine and SHA256-checked;
2
+ `setup` also installs the scratch Postgres (scratch.py).
3
+
4
+ It lives in ~/.cache/postgresledger/ ($XDG_CACHE_HOME respected), so every project
5
+ on the machine shares one copy. PGL_PGSCHEMA points to another binary.
6
+ pgschema has no Windows build; on Windows run postgresledger inside WSL2.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ import platform
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import urllib.request
18
+ from pathlib import Path
19
+
20
+ VERSION = "1.13.1"
21
+ # sha256 of the release assets, per <os>-<arch>
22
+ SHA256 = {
23
+ "linux-amd64": "f726513e19d12f7667ba0b6a24e7a3a568e576708de030ef88f4ba3790e85946",
24
+ "linux-arm64": "8202d3e2cba000872627472549dbfb4618d2b256b22e7144b4ff6ecd476f36c1",
25
+ "darwin-amd64": "72d26778679407cec0816d1ec26fb123d2fd80120817977051c53e86b6afced5",
26
+ "darwin-arm64": "15af7abf0b7e545a98452f2eed62650a7dc8aa6abcc7f54dd4f0f6e234238a43",
27
+ }
28
+ ARCH = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}
29
+
30
+
31
+ def cache_dir() -> Path:
32
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "postgresledger"
33
+
34
+
35
+ def binary_path() -> Path:
36
+ return cache_dir() / f"pgschema-{VERSION}"
37
+
38
+
39
+ def target() -> str:
40
+ """This machine as pgschema names its builds, e.g. linux-amd64."""
41
+ os_name = platform.system().lower()
42
+ if os_name == "windows":
43
+ sys.exit("pgschema has no Windows build - run postgresledger inside WSL2")
44
+ architecture = ARCH.get(platform.machine().lower())
45
+ build = f"{os_name}-{architecture}"
46
+ if build not in SHA256:
47
+ sys.exit(f"no pgschema build for {platform.system()} {platform.machine()}")
48
+ return build
49
+
50
+
51
+ def sha256(path: Path) -> str:
52
+ return hashlib.sha256(path.read_bytes()).hexdigest()
53
+
54
+
55
+ def install() -> Path:
56
+ """Download the pinned pgschema unless the right one is already there."""
57
+ build = target()
58
+ binary = binary_path()
59
+ expected_checksum = SHA256[build]
60
+ if binary.exists() and sha256(binary) == expected_checksum:
61
+ print(f"ok pgschema {VERSION} ({build}) already in {binary}")
62
+ return binary
63
+ url = f"https://github.com/pgplex/pgschema/releases/download/v{VERSION}/pgschema-{VERSION}-{build}"
64
+ print(f"download {url}")
65
+ binary.parent.mkdir(parents=True, exist_ok=True)
66
+ # downloaded next to the binary and renamed only when complete and checked
67
+ partial_binary = binary.with_suffix(".part")
68
+ with urllib.request.urlopen(url) as response:
69
+ partial_binary.write_bytes(response.read())
70
+ actual_checksum = sha256(partial_binary)
71
+ if actual_checksum != expected_checksum:
72
+ partial_binary.unlink()
73
+ sys.exit(f"pgschema checksum mismatch: expected {expected_checksum}, got {actual_checksum}")
74
+ partial_binary.chmod(0o755)
75
+ partial_binary.replace(binary)
76
+ print(f"ok pgschema {VERSION} ({build}) -> {binary}")
77
+ return binary
78
+
79
+
80
+ def locate() -> str:
81
+ """PGL_PGSCHEMA, then the downloaded binary, then pgschema in PATH."""
82
+ if configured_binary := os.environ.get("PGL_PGSCHEMA"):
83
+ return configured_binary
84
+ if binary_path().exists():
85
+ return str(binary_path())
86
+ if binary_in_path := shutil.which("pgschema"):
87
+ return binary_in_path
88
+ sys.exit("pgschema not found - run `pgl setup` to download it")
89
+
90
+
91
+ def check_tool(name: str, version_arguments: list[str], hint: str, required: bool = True) -> bool:
92
+ """Print whether the tool is installed (with its version); False only when a required tool is missing."""
93
+ if not shutil.which(name):
94
+ status = "MISSING" if required else "missing"
95
+ print(f"{status} {name} - {hint}")
96
+ return not required
97
+ result = subprocess.run([name, *version_arguments], capture_output=True, text=True, check=False)
98
+ output_lines = result.stdout.strip().splitlines()
99
+ version = output_lines[0] if output_lines else name
100
+ print(f"ok {version}")
101
+ return True
102
+
103
+
104
+ def setup() -> int:
105
+ """Download pgschema and the scratch Postgres, check the other tools postgresledger needs."""
106
+ from . import scratch
107
+
108
+ print(f"ok Python {platform.python_version()}")
109
+ install()
110
+ scratch.install()
111
+ psql_found = check_tool("psql", ["--version"], "install the PostgreSQL client (postgresql-client / libpq)")
112
+ return 0 if psql_found else 1
@@ -0,0 +1,156 @@
1
+ """A throwaway Postgres on this machine, for the work that needs a scratch database.
2
+
3
+ Building "every migration applied", generating a migration, checking drift,
4
+ and capture need a database postgresledger may drop and recreate. That never
5
+ happens on a real server: each command starts its own Postgres in a temporary
6
+ folder on a free local port, and deletes it when the command ends.
7
+
8
+ The server binaries are a pinned build from Maven Central (io.zonky.test.postgres,
9
+ relocatable), downloaded once per machine into ~/.cache/postgresledger/ and SHA256-checked.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import os
16
+ import platform
17
+ import shutil
18
+ import socket
19
+ import subprocess
20
+ import sys
21
+ import tarfile
22
+ import tempfile
23
+ import urllib.request
24
+ import zipfile
25
+ from collections.abc import Iterator
26
+ from contextlib import contextmanager
27
+ from pathlib import Path
28
+
29
+ from .config import Connection
30
+ from .pgschema import cache_dir, sha256
31
+
32
+ VERSION = "17.11.0"
33
+ # sha256 of the jars, per platform (cross-checked with Maven Central's .sha1)
34
+ SHA256 = {
35
+ "linux-amd64": "0dd7b72b6f335b8ecfb355fa24c5781e8a93edd09880bb77eb52ebbf29b3e96d",
36
+ "linux-arm64v8": "8b042e0ea418b1927d95399207950da0734d27fe01e29503ade3bdc464ad4c2b",
37
+ "darwin-amd64": "d464ff178e9860ba204662ac23fa547504b7fd392392ff2fb92e3fd73b5bdb64",
38
+ "darwin-arm64v8": "a1c2786acb0c398f9b2d76806fc52f5dc8b222cbc8e9383a9b9702084daaf3a5",
39
+ }
40
+ # <os>-<arch> -> the name of the Maven Central build
41
+ BUILD = {
42
+ "linux-amd64": "linux-amd64",
43
+ "linux-arm64": "linux-arm64v8",
44
+ "darwin-amd64": "darwin-amd64",
45
+ "darwin-arm64": "darwin-arm64v8",
46
+ }
47
+ ARCH = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}
48
+
49
+
50
+ def install_dir() -> Path:
51
+ return cache_dir() / f"postgres-{VERSION}"
52
+
53
+
54
+ def target() -> str:
55
+ """This machine as Maven Central names the Postgres builds, e.g. linux-arm64v8."""
56
+ os_name = platform.system().lower()
57
+ architecture = ARCH.get(platform.machine().lower())
58
+ platform_name = f"{os_name}-{architecture}"
59
+ if platform_name not in BUILD:
60
+ sys.exit(f"no Postgres build for {platform.system()} {platform.machine()} (Windows: use WSL2)")
61
+ return BUILD[platform_name]
62
+
63
+
64
+ def install() -> Path:
65
+ """Download and unpack the pinned Postgres server unless it is already there."""
66
+ destination = install_dir()
67
+ if (destination / "bin" / "pg_ctl").exists():
68
+ print(f"ok Postgres {VERSION} (scratch server) already in {destination}")
69
+ return destination
70
+ build = target()
71
+ url = (f"https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-{build}/{VERSION}/"
72
+ f"embedded-postgres-binaries-{build}-{VERSION}.jar")
73
+ print(f"download {url}")
74
+ with urllib.request.urlopen(url) as response:
75
+ jar = response.read()
76
+ with tempfile.NamedTemporaryFile() as jar_file:
77
+ jar_file.write(jar)
78
+ jar_file.flush()
79
+ actual_checksum = sha256(Path(jar_file.name))
80
+ expected_checksum = SHA256[build]
81
+ if actual_checksum != expected_checksum:
82
+ sys.exit(f"Postgres checksum mismatch: expected {expected_checksum}, got {actual_checksum}")
83
+
84
+ # the jar holds one .txz archive with the server; unpacked next to the destination,
85
+ # renamed only when complete
86
+ partial_destination = destination.with_name(destination.name + ".part")
87
+ shutil.rmtree(partial_destination, ignore_errors=True)
88
+ with zipfile.ZipFile(io.BytesIO(jar)) as jar_archive:
89
+ archive_names = [name for name in jar_archive.namelist() if name.endswith(".txz")]
90
+ server_archive = jar_archive.read(archive_names[0])
91
+ with tarfile.open(fileobj=io.BytesIO(server_archive), mode="r:xz") as tar:
92
+ # Python versions with extraction filters: keep permissions, block paths outside the folder
93
+ if hasattr(tarfile, "tar_filter"):
94
+ tar.extractall(partial_destination, filter="tar")
95
+ else:
96
+ tar.extractall(partial_destination)
97
+ partial_destination.replace(destination)
98
+ print(f"ok Postgres {VERSION} (scratch server) -> {destination}")
99
+ return destination
100
+
101
+
102
+ def free_port() -> int:
103
+ """A local port nothing listens on: the operating system picks it when binding to port 0."""
104
+ with socket.socket() as probe:
105
+ probe.bind(("127.0.0.1", 0))
106
+ return probe.getsockname()[1]
107
+
108
+
109
+ def run_server_program(command: list[str]) -> None:
110
+ """Run initdb or pg_ctl; if it fails, show its output and stop pgl."""
111
+ program = Path(command[0]).name
112
+ try:
113
+ subprocess.run(command, text=True, capture_output=True, check=True)
114
+ except subprocess.CalledProcessError as error:
115
+ # the program ran and failed: its own output says why
116
+ program_output = (error.stdout + error.stderr).strip()
117
+ sys.exit(f"scratch Postgres: {program} failed:\n{program_output}")
118
+ except OSError as error:
119
+ # the program could not run at all, e.g. the binary is missing
120
+ sys.exit(f"scratch Postgres: could not run {program}: {error}")
121
+
122
+
123
+ @contextmanager
124
+ def server() -> Iterator[Connection]:
125
+ """Start a throwaway Postgres, yield a connection to its `postgres` database, delete it afterwards."""
126
+ bin_dir = install_dir() / "bin"
127
+ if not (bin_dir / "pg_ctl").exists():
128
+ sys.exit("the scratch Postgres is not installed - run `pgl setup`")
129
+ if hasattr(os, "geteuid") and os.geteuid() == 0:
130
+ sys.exit("the scratch Postgres cannot run as root - run pgl as a normal user")
131
+ with tempfile.TemporaryDirectory(prefix="postgresledger-") as temporary_dir:
132
+ data_dir = Path(temporary_dir) / "data"
133
+ log_file = Path(temporary_dir) / "server.log"
134
+ port = free_port()
135
+ run_server_program([
136
+ str(bin_dir / "initdb"),
137
+ "-D", str(data_dir),
138
+ "-U", "postgres",
139
+ "-A", "trust", # no passwords: only this machine can reach it
140
+ "-E", "UTF8",
141
+ "--locale=C",
142
+ "--no-sync", # a throwaway database does not need to survive a crash
143
+ ])
144
+ server_options = f"-p {port} -c listen_addresses=127.0.0.1 -c unix_socket_directories='' -c fsync=off"
145
+ run_server_program([
146
+ str(bin_dir / "pg_ctl"),
147
+ "-D", str(data_dir),
148
+ "-l", str(log_file),
149
+ "-w", "start",
150
+ "-o", server_options,
151
+ ])
152
+ try:
153
+ yield Connection("127.0.0.1", port, "postgres", "", "postgres", "disable")
154
+ finally:
155
+ stop_command = [str(bin_dir / "pg_ctl"), "-D", str(data_dir), "-m", "immediate", "-w", "stop"]
156
+ subprocess.run(stop_command, capture_output=True, check=False)