postgresql-testing 0.0.1__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.
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: postgresql-testing
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: PostgreSQL testing helpers
|
|
5
|
+
Author: Oli Russell
|
|
6
|
+
Author-email: Oli Russell <mrxoliver@gmail.com>
|
|
7
|
+
Requires-Dist: postgresql-binaries
|
|
8
|
+
Requires-Dist: psycopg[binary]==3.*
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Project-URL: repository, https://github.com/leontrolski/postgresql-testing
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Postgresql testing
|
|
14
|
+
|
|
15
|
+
Simple Postgres helpers for testing with Python - no docker, brew, apt, etc - uses [postgresql-binaries](https://github.com/leontrolski/postgresql-binaries).
|
|
16
|
+
|
|
17
|
+
```shell
|
|
18
|
+
pip install postgresql-testing 'postgresql-binaries==18.*'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then to use, eg:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import postgresql_testing
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(scope="session")
|
|
27
|
+
def db() -> Iterator[str]:
|
|
28
|
+
config = postgresql_testing.DatabaseConfig.default("testing-db")
|
|
29
|
+
postgresql_testing.initdb(config.directory, on_existing="use")
|
|
30
|
+
with postgresql_testing.serve(config):
|
|
31
|
+
postgresql_testing.ensure_user(config)
|
|
32
|
+
postgresql_testing.create_database(config, on_existing="replace")
|
|
33
|
+
yield config.dsn
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
There are various useful flags and things - the source code is short enough to just dive in.
|
|
37
|
+
|
|
38
|
+
<hr>
|
|
39
|
+
|
|
40
|
+
There are a couple of helpers for creating/using template dbs and tar files. I have some vague long term plan for some kind of "Docker layers for migrated databases" with clever caching, but I'm not quite sure what it looks like yet.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Postgresql testing
|
|
2
|
+
|
|
3
|
+
Simple Postgres helpers for testing with Python - no docker, brew, apt, etc - uses [postgresql-binaries](https://github.com/leontrolski/postgresql-binaries).
|
|
4
|
+
|
|
5
|
+
```shell
|
|
6
|
+
pip install postgresql-testing 'postgresql-binaries==18.*'
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Then to use, eg:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import postgresql_testing
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(scope="session")
|
|
15
|
+
def db() -> Iterator[str]:
|
|
16
|
+
config = postgresql_testing.DatabaseConfig.default("testing-db")
|
|
17
|
+
postgresql_testing.initdb(config.directory, on_existing="use")
|
|
18
|
+
with postgresql_testing.serve(config):
|
|
19
|
+
postgresql_testing.ensure_user(config)
|
|
20
|
+
postgresql_testing.create_database(config, on_existing="replace")
|
|
21
|
+
yield config.dsn
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
There are various useful flags and things - the source code is short enough to just dive in.
|
|
25
|
+
|
|
26
|
+
<hr>
|
|
27
|
+
|
|
28
|
+
There are a couple of helpers for creating/using template dbs and tar files. I have some vague long term plan for some kind of "Docker layers for migrated databases" with clever caching, but I'm not quite sure what it looks like yet.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "postgresql-testing"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "PostgreSQL testing helpers"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Oli Russell", email = "mrxoliver@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"postgresql-binaries",
|
|
12
|
+
"psycopg[binary]==3.*",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
repository = "https://github.com/leontrolski/postgresql-testing"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.11.7,<0.12.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
test = [
|
|
25
|
+
"postgresql-binaries==15.*",
|
|
26
|
+
"pytest",
|
|
27
|
+
"mypy",
|
|
28
|
+
"ruff",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
line-length = 120
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Iterator, Literal, Self
|
|
9
|
+
import postgresql_binaries
|
|
10
|
+
import psycopg
|
|
11
|
+
import tarfile
|
|
12
|
+
|
|
13
|
+
DEFAULT_DIR = Path("/tmp/.postgresql_testing")
|
|
14
|
+
SUPERUSER = "postgres"
|
|
15
|
+
ROOT_DATABASE = "postgres"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(kw_only=True)
|
|
19
|
+
class ClusterConfig:
|
|
20
|
+
host: str
|
|
21
|
+
port: int
|
|
22
|
+
stderr: Path
|
|
23
|
+
directory: Path
|
|
24
|
+
|
|
25
|
+
def superuser(self) -> DatabaseConfig:
|
|
26
|
+
return DatabaseConfig(
|
|
27
|
+
user=SUPERUSER,
|
|
28
|
+
password="",
|
|
29
|
+
database=ROOT_DATABASE,
|
|
30
|
+
host=self.host,
|
|
31
|
+
port=self.port,
|
|
32
|
+
stderr=self.stderr,
|
|
33
|
+
directory=self.directory,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(kw_only=True)
|
|
38
|
+
class DatabaseConfig(ClusterConfig):
|
|
39
|
+
user: str
|
|
40
|
+
password: str
|
|
41
|
+
database: str
|
|
42
|
+
dsn: str = ""
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
self.dsn = f"postgres://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def default(
|
|
49
|
+
cls,
|
|
50
|
+
database: str,
|
|
51
|
+
*,
|
|
52
|
+
user: str = "testing",
|
|
53
|
+
password: str = "",
|
|
54
|
+
port: int = 8421,
|
|
55
|
+
directory: Path = DEFAULT_DIR / "database",
|
|
56
|
+
) -> Self:
|
|
57
|
+
logs = directory.parent / "logs"
|
|
58
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
return cls(
|
|
60
|
+
user=user,
|
|
61
|
+
password=password,
|
|
62
|
+
host="localhost",
|
|
63
|
+
port=port,
|
|
64
|
+
database=database,
|
|
65
|
+
stderr=logs / f"{database}.log",
|
|
66
|
+
directory=directory,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def initdb(
|
|
71
|
+
directory: Path,
|
|
72
|
+
*,
|
|
73
|
+
on_existing: Literal["raise", "use", "replace"] = "raise",
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Call `initdb` with some sensible arguments for testing."""
|
|
76
|
+
if directory.exists():
|
|
77
|
+
if on_existing == "raise":
|
|
78
|
+
raise RuntimeError(f"Directory {directory} already exists")
|
|
79
|
+
if on_existing == "replace":
|
|
80
|
+
shutil.rmtree(directory)
|
|
81
|
+
if on_existing == "use":
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
cmd: list[str] = [
|
|
86
|
+
str(postgresql_binaries.bin() / "initdb"),
|
|
87
|
+
*("-D", str(directory)),
|
|
88
|
+
*("--username", SUPERUSER),
|
|
89
|
+
*("--auth-host", "trust"),
|
|
90
|
+
*("--wal-segsize", "1"),
|
|
91
|
+
"--no-sync",
|
|
92
|
+
"--no-instructions",
|
|
93
|
+
]
|
|
94
|
+
subprocess.check_call(
|
|
95
|
+
cmd,
|
|
96
|
+
stdout=subprocess.DEVNULL,
|
|
97
|
+
stderr=subprocess.DEVNULL,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@contextmanager
|
|
102
|
+
def serve(c: ClusterConfig) -> Iterator[None]:
|
|
103
|
+
"""Call `postgres` with some sensible arguments."""
|
|
104
|
+
c.stderr.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
with open(c.stderr, "wb") as stderr_f:
|
|
106
|
+
cmd = [
|
|
107
|
+
str(postgresql_binaries.bin() / "postgres"),
|
|
108
|
+
*("-D", str(c.directory)),
|
|
109
|
+
*("-h", str(c.host)),
|
|
110
|
+
*("-p", str(c.port)),
|
|
111
|
+
*("-c", "fsync=off"),
|
|
112
|
+
*("-c", "synchronous_commit=off"),
|
|
113
|
+
*("-c", "full_page_writes=off"),
|
|
114
|
+
# *("-c", "wal_level=minimal"),
|
|
115
|
+
# *("-c", "log_statement=all"),
|
|
116
|
+
]
|
|
117
|
+
process = subprocess.Popen(cmd, stderr=stderr_f)
|
|
118
|
+
try:
|
|
119
|
+
_try_connect(c.superuser().dsn)
|
|
120
|
+
yield
|
|
121
|
+
finally:
|
|
122
|
+
process.terminate()
|
|
123
|
+
process.wait()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@contextmanager
|
|
127
|
+
def create_template(
|
|
128
|
+
c: DatabaseConfig,
|
|
129
|
+
*,
|
|
130
|
+
template: str,
|
|
131
|
+
on_existing: Literal["raise", "replace"] = "replace",
|
|
132
|
+
) -> Iterator[DatabaseConfig]:
|
|
133
|
+
with psycopg.connect(c.superuser().dsn) as conn, conn.cursor() as cur:
|
|
134
|
+
conn.autocommit = True
|
|
135
|
+
template_exists = bool(cur.execute(f"SELECT 1 FROM pg_database WHERE datname = '{template}'").fetchall())
|
|
136
|
+
if template_exists:
|
|
137
|
+
if on_existing == "raise":
|
|
138
|
+
raise RuntimeError(f"Template database {template} already exists")
|
|
139
|
+
if on_existing == "replace":
|
|
140
|
+
cur.execute(f"UPDATE pg_database SET datistemplate = false WHERE datname='{template}'")
|
|
141
|
+
cur.execute(f'DROP DATABASE "{template}"')
|
|
142
|
+
|
|
143
|
+
cur.execute(f'CREATE DATABASE "{template}" OWNER "{c.user}"')
|
|
144
|
+
yield replace(c, database=template)
|
|
145
|
+
cur.execute(f"UPDATE pg_database SET datistemplate = true WHERE datname='{template}'")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ensure_user(c: DatabaseConfig) -> None:
|
|
149
|
+
with psycopg.connect(c.superuser().dsn) as conn, conn.cursor() as cur:
|
|
150
|
+
try:
|
|
151
|
+
cur.execute(f"CREATE ROLE \"{c.user}\" LOGIN PASSWORD '{c.password}'")
|
|
152
|
+
except psycopg.errors.DuplicateObject:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def create_database(
|
|
157
|
+
c: DatabaseConfig,
|
|
158
|
+
*,
|
|
159
|
+
template: str | None = None,
|
|
160
|
+
on_existing: Literal["raise", "replace"] = "replace",
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Create a Postgres database.
|
|
163
|
+
|
|
164
|
+
- Optionally pass in a template.
|
|
165
|
+
- Takes in the tens of ms for a small db.
|
|
166
|
+
"""
|
|
167
|
+
template_str = "" if template is None else f'WITH TEMPLATE "{template}"'
|
|
168
|
+
sql_create_database = f'CREATE DATABASE "{c.database}" {template_str} OWNER "{c.user}" STRATEGY=FILE_COPY'
|
|
169
|
+
|
|
170
|
+
with psycopg.connect(c.superuser().dsn) as conn, conn.cursor() as cur:
|
|
171
|
+
conn.autocommit = True
|
|
172
|
+
try:
|
|
173
|
+
cur.execute(sql_create_database)
|
|
174
|
+
except psycopg.errors.DuplicateDatabase:
|
|
175
|
+
if on_existing == "raise":
|
|
176
|
+
raise RuntimeError(f"Database {c.database} already exists")
|
|
177
|
+
if on_existing == "replace":
|
|
178
|
+
cur.execute(f'DROP DATABASE "{c.database}"')
|
|
179
|
+
cur.execute(sql_create_database)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def dump_archive(
|
|
183
|
+
directory: Path,
|
|
184
|
+
archive: Path,
|
|
185
|
+
*,
|
|
186
|
+
on_existing: Literal["raise", "replace"] = "replace",
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Dump the entirety of a Postgres cluster to a tar archive.
|
|
189
|
+
|
|
190
|
+
- Archives are not very portable (between system architectures or Postgres
|
|
191
|
+
versions).
|
|
192
|
+
- Takes in the hundreds of ms for a small db.
|
|
193
|
+
"""
|
|
194
|
+
if archive.exists():
|
|
195
|
+
if on_existing == "raise":
|
|
196
|
+
raise RuntimeError(f"Archive {archive} already exists")
|
|
197
|
+
archive.unlink()
|
|
198
|
+
|
|
199
|
+
with tarfile.open(archive, "w") as tar:
|
|
200
|
+
tar.add(directory, arcname=directory.name)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_archive(
|
|
204
|
+
archive: Path,
|
|
205
|
+
directory: Path,
|
|
206
|
+
*,
|
|
207
|
+
on_existing: Literal["raise", "replace"] = "replace",
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Dump the entirety of a Postgres cluster from a tar archive."""
|
|
210
|
+
if directory.exists():
|
|
211
|
+
if on_existing == "raise":
|
|
212
|
+
raise RuntimeError(f"Directory {directory} already exists")
|
|
213
|
+
shutil.rmtree(directory)
|
|
214
|
+
|
|
215
|
+
with tarfile.open(archive) as tar:
|
|
216
|
+
tar.extractall(directory.parent)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _try_connect(dsn: str) -> None:
|
|
220
|
+
for _ in range(500):
|
|
221
|
+
try:
|
|
222
|
+
with psycopg.connect(dsn) as conn:
|
|
223
|
+
conn.execute("SELECT 1")
|
|
224
|
+
return
|
|
225
|
+
except psycopg.OperationalError:
|
|
226
|
+
pass
|
|
227
|
+
raise RuntimeError(f"Could not connect to {dsn}")
|
|
File without changes
|