pytest-pglite 0.1.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.
- pytest_pglite/__init__.py +36 -0
- pytest_pglite/_wasm/PROVENANCE.md +65 -0
- pytest_pglite/_wasm/SHA256SUMS +1 -0
- pytest_pglite/_wasm/pglite-17.5.20261006.tar.xz +0 -0
- pytest_pglite/aot.py +69 -0
- pytest_pglite/artifacts.py +147 -0
- pytest_pglite/cli.py +82 -0
- pytest_pglite/config.py +146 -0
- pytest_pglite/engine.py +348 -0
- pytest_pglite/errors.py +35 -0
- pytest_pglite/plugin.py +341 -0
- pytest_pglite/protocol.py +342 -0
- pytest_pglite/server.py +321 -0
- pytest_pglite/wire/__init__.py +0 -0
- pytest_pglite/wire/server.py +476 -0
- pytest_pglite/wire/state.py +172 -0
- pytest_pglite-0.1.0.dist-info/METADATA +221 -0
- pytest_pglite-0.1.0.dist-info/RECORD +21 -0
- pytest_pglite-0.1.0.dist-info/WHEEL +4 -0
- pytest_pglite-0.1.0.dist-info/entry_points.txt +6 -0
- pytest_pglite-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Native PGlite (PostgreSQL in WebAssembly) for Python test suites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from .config import IsolationMode, PGliteConfig
|
|
8
|
+
from .errors import (
|
|
9
|
+
PGliteArtifactError,
|
|
10
|
+
PGliteConfigurationError,
|
|
11
|
+
PGliteError,
|
|
12
|
+
PGliteProtocolError,
|
|
13
|
+
PGliteQueueTimeoutError,
|
|
14
|
+
PGliteTrapError,
|
|
15
|
+
PGliteUnrecoverableError,
|
|
16
|
+
)
|
|
17
|
+
from .server import PGliteServer
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = version("pytest-pglite")
|
|
21
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
22
|
+
__version__ = "0.0.0.dev0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"IsolationMode",
|
|
26
|
+
"PGliteArtifactError",
|
|
27
|
+
"PGliteConfig",
|
|
28
|
+
"PGliteConfigurationError",
|
|
29
|
+
"PGliteError",
|
|
30
|
+
"PGliteProtocolError",
|
|
31
|
+
"PGliteQueueTimeoutError",
|
|
32
|
+
"PGliteServer",
|
|
33
|
+
"PGliteTrapError",
|
|
34
|
+
"PGliteUnrecoverableError",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Vendored WASM artifact provenance
|
|
2
|
+
|
|
3
|
+
`pytest-pglite` redistributes a prebuilt PostgreSQL 17.5 WebAssembly module so
|
|
4
|
+
that users never need a WebAssembly toolchain. This file records exactly what
|
|
5
|
+
is bundled and how to rebuild it.
|
|
6
|
+
|
|
7
|
+
## Bundled file
|
|
8
|
+
|
|
9
|
+
| File | Size | SHA-256 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `pglite-17.5.20261006.tar.xz` | 5,145,000 | `cea663a6ca74ee8183f223e4a7c2255fe5a86cb4c491545669f95cc9e9370488` |
|
|
12
|
+
|
|
13
|
+
The hash is also recorded in `SHA256SUMS` next to the artifact and is verified
|
|
14
|
+
on every first use.
|
|
15
|
+
|
|
16
|
+
## How it was built
|
|
17
|
+
|
|
18
|
+
The module is produced by this repository's build pipeline
|
|
19
|
+
(`build/scripts/build-wasi.sh`), which is a fork of
|
|
20
|
+
[electric-sql/pglite-build](https://github.com/electric-sql/pglite-build)
|
|
21
|
+
`portable` branch at commit
|
|
22
|
+
`c195113dbaf09488f8d5eeb2db91dacd123b74d0`, plus
|
|
23
|
+
`build/patches/0001-vector-static.patch`:
|
|
24
|
+
|
|
25
|
+
- statically links pgvector 0.8.0 and installs `vector.control` /
|
|
26
|
+
`vector--0.8.0.sql`
|
|
27
|
+
- registers the 104 SQL-visible pgvector symbols (including the 16 explicit
|
|
28
|
+
`AS 'MODULE_PATHNAME', 'symbol'` aliases) in the WASI dlopen/dlsym shim
|
|
29
|
+
- calls pgvector's `_PG_init` (renamed to avoid a duplicate symbol with
|
|
30
|
+
plpgsql) so `hnsw.ef_search` and the other GUCs exist
|
|
31
|
+
- fixes the shim's one-element `dltab` array and off-by-one, which corrupted
|
|
32
|
+
memory on the second `dlopen`
|
|
33
|
+
- clears `ActivePortal` during trap recovery so a simple-query error no longer
|
|
34
|
+
wedges the only backend
|
|
35
|
+
- ships a `lib/postgresql/vector.so` placeholder (PostgreSQL checks that the
|
|
36
|
+
library path exists before calling `dlopen`)
|
|
37
|
+
- bounds compiler parallelism through `PGLITE_JOBS` (upstream defaults to
|
|
38
|
+
`nproc`)
|
|
39
|
+
|
|
40
|
+
Build: 2026-10-06, Linux x86_64, wasi-sdk 25.0, PostgreSQL 17.5
|
|
41
|
+
(`REL_17_5_WASM`), pgvector 0.8.0.
|
|
42
|
+
|
|
43
|
+
## Contents
|
|
44
|
+
|
|
45
|
+
~~~text
|
|
46
|
+
tmp/pglite/bin/pglite.wasi PostgreSQL 17.5 + pgvector (wasm32-wasip1), 23 MB
|
|
47
|
+
tmp/pglite/password superuser password ("password")
|
|
48
|
+
tmp/pglite/lib/postgresql/*.so library placeholders (plpgsql, snowball, vector)
|
|
49
|
+
tmp/pglite/share/postgresql/... initdb inputs and extension SQL
|
|
50
|
+
~~~
|
|
51
|
+
|
|
52
|
+
## Rebuilding
|
|
53
|
+
|
|
54
|
+
See `build/README.md` in the repository root. After a successful build:
|
|
55
|
+
|
|
56
|
+
1. Boot the new module and run `pytest tests/test_vector.py` against it (point
|
|
57
|
+
the tests at it with `--pglite-wasm /path/to/tmp/pglite`).
|
|
58
|
+
2. Copy the tarball here under a dated filename, update `SHA256SUMS` and this
|
|
59
|
+
file, and bump `ARTIFACT_FILENAME` in `src/pytest_pglite/artifacts.py`.
|
|
60
|
+
|
|
61
|
+
## Licences
|
|
62
|
+
|
|
63
|
+
PGlite is dual-licensed Apache-2.0 / PostgreSQL License. The postgres-pglite
|
|
64
|
+
changes and pgvector are PostgreSQL License. libpglite is MIT. See
|
|
65
|
+
`THIRD_PARTY_NOTICES.md`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cea663a6ca74ee8183f223e4a7c2255fe5a86cb4c491545669f95cc9e9370488 pglite-17.5.20261006.tar.xz
|
|
Binary file
|
pytest_pglite/aot.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""On-disk cache for ahead-of-time compiled WASM modules.
|
|
2
|
+
|
|
3
|
+
Compiling the 23 MB pglite module takes about a second with Cranelift.
|
|
4
|
+
Wasmtime can serialise the compiled module and deserialise it in tens of
|
|
5
|
+
milliseconds, which makes per-worker startup much faster. Serialised modules
|
|
6
|
+
are only valid for the exact engine build that produced them, so the cache
|
|
7
|
+
key includes the wasmtime version and the platform.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import platform
|
|
14
|
+
import sys
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import wasmtime
|
|
19
|
+
|
|
20
|
+
from .errors import PGliteArtifactError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _wasmtime_version() -> str:
|
|
24
|
+
try:
|
|
25
|
+
return version("wasmtime")
|
|
26
|
+
except PackageNotFoundError: # pragma: no cover - vendored wasmtime
|
|
27
|
+
return "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cache_key(wasm_path: Path, artifact_digest: str | None = None) -> str:
|
|
31
|
+
if artifact_digest is None:
|
|
32
|
+
digest = hashlib.sha256(wasm_path.read_bytes()).hexdigest()
|
|
33
|
+
else:
|
|
34
|
+
digest = artifact_digest
|
|
35
|
+
machine = platform.machine() or "unknown"
|
|
36
|
+
return (
|
|
37
|
+
f"wasmtime{_wasmtime_version()}-py{sys.version_info[0]}{sys.version_info[1]}-"
|
|
38
|
+
f"{sys.platform}-{machine}-{digest[:16]}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_module(
|
|
43
|
+
engine: wasmtime.Engine,
|
|
44
|
+
wasm_path: Path,
|
|
45
|
+
cache_dir: Path,
|
|
46
|
+
key: str,
|
|
47
|
+
use_cache: bool = True,
|
|
48
|
+
) -> wasmtime.Module:
|
|
49
|
+
"""Load a module, using the AOT cache when available."""
|
|
50
|
+
cached = cache_dir / f"{key}.cwasm"
|
|
51
|
+
if use_cache and cached.is_file():
|
|
52
|
+
try:
|
|
53
|
+
return wasmtime.Module.deserialize(engine, cached.read_bytes())
|
|
54
|
+
except wasmtime.WasmtimeError:
|
|
55
|
+
cached.unlink(missing_ok=True)
|
|
56
|
+
try:
|
|
57
|
+
module = wasmtime.Module.from_file(engine, str(wasm_path))
|
|
58
|
+
except wasmtime.WasmtimeError as exc: # pragma: no cover - corrupt artifact
|
|
59
|
+
raise PGliteArtifactError(f"failed to compile {wasm_path}: {exc}") from exc
|
|
60
|
+
if use_cache:
|
|
61
|
+
try:
|
|
62
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
temporary = cached.with_suffix(".cwasm.tmp")
|
|
64
|
+
temporary.write_bytes(module.serialize())
|
|
65
|
+
temporary.replace(cached)
|
|
66
|
+
except OSError:
|
|
67
|
+
# A read-only or full cache must never break the run.
|
|
68
|
+
pass
|
|
69
|
+
return module
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Locating, verifying and unpacking the bundled PGlite WASM artifact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tarfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_cache_dir
|
|
12
|
+
|
|
13
|
+
from .errors import PGliteArtifactError
|
|
14
|
+
|
|
15
|
+
ARTIFACT_FILENAME = "pglite-17.5.20261006.tar.xz"
|
|
16
|
+
WASM_DIR = Path(__file__).resolve().parent / "_wasm"
|
|
17
|
+
PREFIX_RELATIVE = Path("tmp") / "pglite"
|
|
18
|
+
WASM_RELATIVE = Path("bin") / "pglite.wasi"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def artifact_path() -> Path:
|
|
22
|
+
"""Return the path of the main vendored artifact tarball."""
|
|
23
|
+
return WASM_DIR / ARTIFACT_FILENAME
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def artifact_paths() -> tuple[Path, ...]:
|
|
27
|
+
"""All vendored tarballs, in extraction order."""
|
|
28
|
+
return (artifact_path(),)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
|
|
32
|
+
digest = hashlib.sha256()
|
|
33
|
+
with path.open("rb") as handle:
|
|
34
|
+
for chunk in iter(lambda: handle.read(chunk_size), b""):
|
|
35
|
+
digest.update(chunk)
|
|
36
|
+
return digest.hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def expected_digest(filename: str = ARTIFACT_FILENAME) -> str:
|
|
40
|
+
sums = WASM_DIR / "SHA256SUMS"
|
|
41
|
+
if not sums.is_file():
|
|
42
|
+
raise PGliteArtifactError(f"missing checksum file: {sums}")
|
|
43
|
+
for line in sums.read_text(encoding="utf-8").splitlines():
|
|
44
|
+
parts = line.split()
|
|
45
|
+
if len(parts) == 2 and parts[1] == filename:
|
|
46
|
+
return parts[0]
|
|
47
|
+
raise PGliteArtifactError(f"no checksum recorded for {filename}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def verify_artifact(path: Path | None = None) -> str:
|
|
51
|
+
"""Verify a vendored tarball and return its sha256 digest."""
|
|
52
|
+
path = path or artifact_path()
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
raise PGliteArtifactError(
|
|
55
|
+
f"bundled PGlite artifact not found at {path}; "
|
|
56
|
+
"install the package from a wheel/sdist that includes it"
|
|
57
|
+
)
|
|
58
|
+
digest = sha256_file(path)
|
|
59
|
+
expected = expected_digest(path.name)
|
|
60
|
+
if digest != expected:
|
|
61
|
+
raise PGliteArtifactError(
|
|
62
|
+
f"PGlite artifact checksum mismatch for {path}: "
|
|
63
|
+
f"expected {expected}, got {digest}"
|
|
64
|
+
)
|
|
65
|
+
return digest
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_tar(path: Path, destination: Path) -> None:
|
|
69
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
with tarfile.open(path, "r:xz") as archive:
|
|
71
|
+
for member in archive.getmembers():
|
|
72
|
+
member_path = Path(member.name)
|
|
73
|
+
if member_path.is_absolute() or ".." in member_path.parts:
|
|
74
|
+
raise PGliteArtifactError(
|
|
75
|
+
f"refusing to extract unsafe path {member.name!r} from {path}"
|
|
76
|
+
)
|
|
77
|
+
archive.extractall(destination, filter="data")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ensure_prefix(
|
|
81
|
+
wasm_path: Path | None = None, use_cache: bool = True
|
|
82
|
+
) -> tuple[Path, str]:
|
|
83
|
+
"""Return (prefix_root, cache_key).
|
|
84
|
+
|
|
85
|
+
``prefix_root`` contains ``bin/pglite.wasi`` plus the ``share`` and ``lib``
|
|
86
|
+
trees the WASM module needs. For the bundled artifact the tarball is
|
|
87
|
+
verified and extracted once into the user cache directory.
|
|
88
|
+
"""
|
|
89
|
+
if wasm_path is not None:
|
|
90
|
+
candidate = wasm_path.expanduser()
|
|
91
|
+
if candidate.is_file():
|
|
92
|
+
candidate = candidate.parent.parent
|
|
93
|
+
if not (candidate / WASM_RELATIVE).is_file():
|
|
94
|
+
raise PGliteArtifactError(
|
|
95
|
+
f"custom wasm path {wasm_path} does not contain bin/pglite.wasi"
|
|
96
|
+
)
|
|
97
|
+
digest = sha256_file(candidate / WASM_RELATIVE)
|
|
98
|
+
return candidate, digest
|
|
99
|
+
|
|
100
|
+
path = artifact_path()
|
|
101
|
+
digest = verify_artifact(path)
|
|
102
|
+
cache_root = Path(user_cache_dir("pytest-pglite")) / "artifacts" / digest[:16]
|
|
103
|
+
marker = cache_root / ".complete"
|
|
104
|
+
prefix = cache_root / PREFIX_RELATIVE
|
|
105
|
+
if not (use_cache and marker.is_file() and (prefix / WASM_RELATIVE).is_file()):
|
|
106
|
+
if cache_root.exists():
|
|
107
|
+
shutil.rmtree(cache_root, ignore_errors=True)
|
|
108
|
+
for tarball in artifact_paths():
|
|
109
|
+
if tarball.is_file():
|
|
110
|
+
verify_artifact(tarball)
|
|
111
|
+
_extract_tar(tarball, cache_root)
|
|
112
|
+
marker.write_text(digest, encoding="utf-8")
|
|
113
|
+
return prefix, digest
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _link_or_copy(source: str, destination: str) -> str:
|
|
117
|
+
try:
|
|
118
|
+
os.link(source, destination)
|
|
119
|
+
return destination
|
|
120
|
+
except OSError:
|
|
121
|
+
return shutil.copy2(source, destination)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def prepare_work_dir(prefix: Path, work_dir: Path) -> Path:
|
|
125
|
+
"""Copy the prefix into a fresh work directory and return the work dir.
|
|
126
|
+
|
|
127
|
+
Hard links are used when possible so that starting a worker is cheap; the
|
|
128
|
+
WASM file is large but read-only.
|
|
129
|
+
"""
|
|
130
|
+
target = work_dir / PREFIX_RELATIVE
|
|
131
|
+
if target.exists():
|
|
132
|
+
raise PGliteArtifactError(f"work directory already populated: {target}")
|
|
133
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
shutil.copytree(prefix, target, copy_function=_link_or_copy, symlinks=False)
|
|
135
|
+
dev = work_dir / "dev"
|
|
136
|
+
dev.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
urandom = dev / "urandom"
|
|
138
|
+
if not urandom.exists():
|
|
139
|
+
urandom.write_bytes(os.urandom(256))
|
|
140
|
+
return work_dir
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def wasm_file(prefix: Path) -> Path:
|
|
144
|
+
wasm = prefix / WASM_RELATIVE
|
|
145
|
+
if not wasm.is_file():
|
|
146
|
+
raise PGliteArtifactError(f"missing WASM module: {wasm}")
|
|
147
|
+
return wasm
|
pytest_pglite/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Standalone server entry point: ``pytest-pglite``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import PGliteConfig
|
|
12
|
+
from .server import PGliteServer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="pytest-pglite",
|
|
18
|
+
description="Run a PGlite server (PostgreSQL 17.5 in WebAssembly).",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("--work-dir", default=None, help="Database directory.")
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--extensions",
|
|
23
|
+
default="",
|
|
24
|
+
help="Comma separated extensions to create at startup.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--tcp-address",
|
|
28
|
+
default=None,
|
|
29
|
+
metavar="HOST:PORT",
|
|
30
|
+
help="Listen on TCP as well (port 0 = OS-assigned).",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--wasm", default=None, help="Custom bin/pglite.wasi build.")
|
|
33
|
+
parser.add_argument("--keep-tmp", action="store_true", help="Keep files.")
|
|
34
|
+
parser.add_argument("--log-level", default="INFO")
|
|
35
|
+
return parser.parse_args(argv)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> int:
|
|
39
|
+
args = _parse_args(argv)
|
|
40
|
+
host = "127.0.0.1"
|
|
41
|
+
port = 0
|
|
42
|
+
if args.tcp_address:
|
|
43
|
+
parsed_host, _, parsed_port = args.tcp_address.rpartition(":")
|
|
44
|
+
host = parsed_host or "127.0.0.1"
|
|
45
|
+
if parsed_port.isdigit():
|
|
46
|
+
port = int(parsed_port)
|
|
47
|
+
extensions = tuple(
|
|
48
|
+
part.strip() for part in args.extensions.split(",") if part.strip()
|
|
49
|
+
)
|
|
50
|
+
server = PGliteServer(
|
|
51
|
+
PGliteConfig(
|
|
52
|
+
work_dir=Path(args.work_dir) if args.work_dir else None,
|
|
53
|
+
extensions=extensions,
|
|
54
|
+
tcp=bool(args.tcp_address),
|
|
55
|
+
tcp_host=host,
|
|
56
|
+
tcp_port=port,
|
|
57
|
+
wasm_path=Path(args.wasm) if args.wasm else None,
|
|
58
|
+
keep_tmp=bool(args.keep_tmp),
|
|
59
|
+
log_level=args.log_level or "INFO",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
server.start()
|
|
63
|
+
print(f"PGlite ready: {server.dsn}")
|
|
64
|
+
print(f"work dir: {server.work_dir}")
|
|
65
|
+
stopped = False
|
|
66
|
+
|
|
67
|
+
def _handle_signal(signum: int, frame: object) -> None:
|
|
68
|
+
nonlocal stopped
|
|
69
|
+
stopped = True
|
|
70
|
+
|
|
71
|
+
signal.signal(signal.SIGINT, _handle_signal)
|
|
72
|
+
signal.signal(signal.SIGTERM, _handle_signal)
|
|
73
|
+
try:
|
|
74
|
+
while not stopped:
|
|
75
|
+
time.sleep(0.2)
|
|
76
|
+
finally:
|
|
77
|
+
server.stop()
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__": # pragma: no cover
|
|
82
|
+
sys.exit(main())
|
pytest_pglite/config.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Configuration models for pytest-pglite.
|
|
2
|
+
|
|
3
|
+
All settings can be provided through environment variables using the
|
|
4
|
+
``PGLITE_`` prefix, for example ``PGLITE_ISOLATION=transaction``, or through
|
|
5
|
+
the pytest command line options documented in :mod:`pytest_pglite.plugin`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import Field, field_validator
|
|
15
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
_EXTENSION_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IsolationMode(StrEnum):
|
|
21
|
+
"""How per-test isolation is achieved."""
|
|
22
|
+
|
|
23
|
+
NONE = "none"
|
|
24
|
+
"""No automatic isolation; the database keeps its state between tests."""
|
|
25
|
+
|
|
26
|
+
SCHEMA = "schema"
|
|
27
|
+
"""Drop and recreate the ``public`` schema around each test (default)."""
|
|
28
|
+
|
|
29
|
+
TRANSACTION = "transaction"
|
|
30
|
+
"""Roll back the fixture connection at the end of each test."""
|
|
31
|
+
|
|
32
|
+
DATABASE = "database"
|
|
33
|
+
"""Reserved; not supported by the single-user PGlite backend."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PGliteConfig(BaseSettings):
|
|
37
|
+
"""Validated runtime configuration for a PGlite server."""
|
|
38
|
+
|
|
39
|
+
model_config = SettingsConfigDict(
|
|
40
|
+
env_prefix="PGLITE_",
|
|
41
|
+
extra="ignore",
|
|
42
|
+
env_file=None,
|
|
43
|
+
enable_decoding=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
pg_version: str = Field(
|
|
47
|
+
default="17.5",
|
|
48
|
+
description="PostgreSQL major.minor version of the bundled WASM build.",
|
|
49
|
+
)
|
|
50
|
+
extensions: tuple[str, ...] = Field(
|
|
51
|
+
default=(),
|
|
52
|
+
description="Extensions created in the database at startup.",
|
|
53
|
+
)
|
|
54
|
+
isolation: IsolationMode = Field(
|
|
55
|
+
default=IsolationMode.SCHEMA,
|
|
56
|
+
description="Per-test isolation strategy.",
|
|
57
|
+
)
|
|
58
|
+
work_dir: Path | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
description="Directory for database files. Defaults to a temp directory.",
|
|
61
|
+
)
|
|
62
|
+
wasm_path: Path | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
description="Directory containing a custom bin/pglite.wasi build.",
|
|
65
|
+
)
|
|
66
|
+
unix_socket: bool = Field(
|
|
67
|
+
default=True,
|
|
68
|
+
description="Listen on a Unix domain socket.",
|
|
69
|
+
)
|
|
70
|
+
tcp: bool = Field(
|
|
71
|
+
default=False,
|
|
72
|
+
description="Also listen on TCP.",
|
|
73
|
+
)
|
|
74
|
+
tcp_host: str = Field(default="127.0.0.1")
|
|
75
|
+
tcp_port: int = Field(
|
|
76
|
+
default=0,
|
|
77
|
+
ge=0,
|
|
78
|
+
le=65535,
|
|
79
|
+
description="TCP port; 0 lets the operating system assign a free port.",
|
|
80
|
+
)
|
|
81
|
+
max_connections: int = Field(default=32, ge=1, le=1024)
|
|
82
|
+
queue_timeout: float = Field(
|
|
83
|
+
default=60.0,
|
|
84
|
+
gt=0,
|
|
85
|
+
description="Seconds a connection waits for the shared backend.",
|
|
86
|
+
)
|
|
87
|
+
startup_timeout: float = Field(default=120.0, gt=0)
|
|
88
|
+
holder_timeout: float | None = Field(
|
|
89
|
+
default=None,
|
|
90
|
+
gt=0,
|
|
91
|
+
description=(
|
|
92
|
+
"Seconds a connection may hold the backend while idle. "
|
|
93
|
+
"None waits forever (clients normally Sync promptly)."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
aot_cache: bool = Field(
|
|
97
|
+
default=True,
|
|
98
|
+
description="Cache the compiled WebAssembly module on disk.",
|
|
99
|
+
)
|
|
100
|
+
template_cache: bool = Field(
|
|
101
|
+
default=True,
|
|
102
|
+
description=("Cache an initialised database directory so workers skip initdb."),
|
|
103
|
+
)
|
|
104
|
+
keep_tmp: bool = Field(default=False)
|
|
105
|
+
log_level: str = Field(default="INFO")
|
|
106
|
+
user: str = Field(default="postgres")
|
|
107
|
+
database: str = Field(default="template1")
|
|
108
|
+
password: str = Field(default="password")
|
|
109
|
+
|
|
110
|
+
@field_validator("extensions", mode="before")
|
|
111
|
+
@classmethod
|
|
112
|
+
def _split_extensions(cls, value: object) -> object:
|
|
113
|
+
if isinstance(value, str):
|
|
114
|
+
return tuple(part.strip() for part in value.split(",") if part.strip())
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
@field_validator("extensions")
|
|
118
|
+
@classmethod
|
|
119
|
+
def _check_extensions(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
120
|
+
seen: list[str] = []
|
|
121
|
+
for name in value:
|
|
122
|
+
normalised = name.strip().lower()
|
|
123
|
+
if not normalised:
|
|
124
|
+
continue
|
|
125
|
+
if not _EXTENSION_RE.match(normalised):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"invalid extension name {name!r}; expected [a-z][a-z0-9_]*"
|
|
128
|
+
)
|
|
129
|
+
if normalised not in seen:
|
|
130
|
+
seen.append(normalised)
|
|
131
|
+
return tuple(seen)
|
|
132
|
+
|
|
133
|
+
@field_validator("work_dir", "wasm_path", mode="before")
|
|
134
|
+
@classmethod
|
|
135
|
+
def _expand(cls, value: object) -> object:
|
|
136
|
+
if isinstance(value, str) and value:
|
|
137
|
+
return Path(value).expanduser()
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
@field_validator("log_level")
|
|
141
|
+
@classmethod
|
|
142
|
+
def _check_log_level(cls, value: str) -> str:
|
|
143
|
+
level = value.upper()
|
|
144
|
+
if level not in {"DEBUG", "INFO", "WARNING", "ERROR"}:
|
|
145
|
+
raise ValueError("log_level must be one of DEBUG, INFO, WARNING, ERROR")
|
|
146
|
+
return level
|