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.
@@ -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
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())
@@ -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