From d6f346c4d0cfd42484736dc34767d219414e7aeb Mon Sep 17 00:00:00 2001 From: Kevin Wenner Date: Sat, 27 Sep 2025 09:32:41 +0000 Subject: [PATCH 1/5] wip extensions api & docs --- README.md | 120 +++++++++++++++++++++ scripts/pgxn_install_extension.py | 86 +++++++++++++++ src/tinypg/__init__.py | 10 ++ src/tinypg/binaries.py | 174 +++++++++++++++++++++++++++++- src/tinypg/context.py | 22 +++- src/tinypg/core.py | 93 ++++++++++++++-- src/tinypg/docs/__init__.py | 1 + src/tinypg/docs/extensions.py | 107 ++++++++++++++++++ src/tinypg/extensions.py | 159 +++++++++++++++++++++++++++ tests/test_extensions.py | 102 ++++++++++++++++++ 10 files changed, 862 insertions(+), 12 deletions(-) create mode 100755 scripts/pgxn_install_extension.py create mode 100644 src/tinypg/docs/__init__.py create mode 100644 src/tinypg/docs/extensions.py create mode 100644 src/tinypg/extensions.py create mode 100644 tests/test_extensions.py diff --git a/README.md b/README.md index 073c7fd..9e137e1 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,25 @@ with tinypg.database() as db_uri: # Use database... # Database automatically cleaned up +# Install the built-in pgcrypto extension and use it immediately +with tinypg.database(extensions=["pgcrypto"]) as db_uri: + import psycopg2 + conn = psycopg2.connect(db_uri) + with conn.cursor() as cur: + cur.execute("SELECT encode(digest('hello', 'sha256'), 'hex')") + print(cur.fetchone()[0]) # -> SHA-256 hash + conn.close() + +# Discover which extensions ship with the bundled PostgreSQL binaries +from tinypg import get_available_extension, list_available_extensions + +available = list_available_extensions() +pgcrypto_manifest = get_available_extension("pgcrypto") + +assert "pgcrypto" in available +assert pgcrypto_manifest is not None +assert pgcrypto_manifest.default_version is not None + # Advanced usage db = tinypg.EphemeralDB(port=5433, cleanup_timeout=300) uri = db.start() @@ -65,6 +84,107 @@ finally: - Python 3.8+ - PostgreSQL source compilation tools (if binaries need to be built) +## Bundled PostgreSQL extensions + +TinyPG downloads the same portable PostgreSQL builds that ship with the +`pg-embed` project and exposes metadata about every extension included with the +distribution. Use :func:`tinypg.list_available_extensions` or +:func:`tinypg.get_available_extension` to inspect this catalog at runtime. The +default PostgreSQL 15 bundle currently includes the following extensions: + +| Extension | Default version | Available versions | +| --- | --- | --- | +| `adminpack` | 2.1 | 1.0, 1.0--1.1, 1.1--2.0, 2.0--2.1 | +| `amcheck` | 1.3 | 1.0, 1.0--1.1, 1.1--1.2, 1.2--1.3 | +| `autoinc` | 1.0 | 1.0 | +| `bloom` | 1.0 | 1.0 | +| `bool_plperl` | 1.0 | 1.0 | +| `bool_plperlu` | 1.0 | 1.0 | +| `btree_gin` | 1.3 | 1.0, 1.0--1.1, 1.1--1.2, 1.2--1.3 | +| `btree_gist` | 1.7 | 1.0--1.1, 1.1--1.2, 1.2, 1.2--1.3, 1.3--1.4, 1.4--1.5, 1.5--1.6, 1.6--1.7 | +| `citext` | 1.6 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3--1.4, 1.4, 1.4--1.5, 1.5--1.6 | +| `cube` | 1.5 | 1.0--1.1, 1.1--1.2, 1.2, 1.2--1.3, 1.3--1.4, 1.4--1.5 | +| `dblink` | 1.2 | 1.0--1.1, 1.1--1.2, 1.2 | +| `dict_int` | 1.0 | 1.0 | +| `dict_xsyn` | 1.0 | 1.0 | +| `earthdistance` | 1.1 | 1.0--1.1, 1.1 | +| `file_fdw` | 1.0 | 1.0 | +| `fuzzystrmatch` | 1.1 | 1.0--1.1, 1.1 | +| `hstore` | 1.8 | 1.1--1.2, 1.2--1.3, 1.3--1.4, 1.4, 1.4--1.5, 1.5--1.6, 1.6--1.7, 1.7--1.8 | +| `hstore_plperl` | 1.0 | 1.0 | +| `hstore_plperlu` | 1.0 | 1.0 | +| `hstore_plpython3u` | 1.0 | 1.0 | +| `insert_username` | 1.0 | 1.0 | +| `intagg` | 1.1 | 1.0--1.1, 1.1 | +| `intarray` | 1.5 | 1.0--1.1, 1.1--1.2, 1.2, 1.2--1.3, 1.3--1.4, 1.4--1.5 | +| `isn` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `jsonb_plperl` | 1.0 | 1.0 | +| `jsonb_plperlu` | 1.0 | 1.0 | +| `jsonb_plpython3u` | 1.0 | 1.0 | +| `lo` | 1.1 | 1.0--1.1, 1.1 | +| `ltree` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `ltree_plpython3u` | 1.0 | 1.0 | +| `moddatetime` | 1.0 | 1.0 | +| `old_snapshot` | 1.0 | 1.0 | +| `pageinspect` | 1.11 | 1.0--1.1, 1.1--1.2, 1.10--1.11, 1.2--1.3, 1.3--1.4, 1.4--1.5, 1.5, 1.5--1.6, 1.6--1.7, 1.7--1.8, 1.8--1.9, 1.9--1.10 | +| `pg_buffercache` | 1.3 | 1.0--1.1, 1.1--1.2, 1.2, 1.2--1.3 | +| `pg_freespacemap` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `pg_prewarm` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `pg_stat_statements` | 1.10 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3--1.4, 1.4, 1.4--1.5, 1.5--1.6, 1.6--1.7, 1.7--1.8, 1.8--1.9, 1.9--1.10 | +| `pg_surgery` | 1.0 | 1.0 | +| `pg_trgm` | 1.6 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3, 1.3--1.4, 1.4--1.5, 1.5--1.6 | +| `pg_visibility` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `pg_walinspect` | 1.0 | 1.0 | +| `pgcrypto` | 1.3 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3 | +| `pgrowlocks` | 1.2 | 1.0--1.1, 1.1--1.2, 1.2 | +| `pgstattuple` | 1.5 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3--1.4, 1.4, 1.4--1.5 | +| `plperl` | 1.0 | 1.0 | +| `plperlu` | 1.0 | 1.0 | +| `plpgsql` | 1.0 | 1.0 | +| `plpython3u` | 1.0 | 1.0 | +| `pltcl` | 1.0 | 1.0 | +| `pltclu` | 1.0 | 1.0 | +| `postgres_fdw` | 1.1 | 1.0, 1.0--1.1 | +| `refint` | 1.0 | 1.0 | +| `seg` | 1.4 | 1.0--1.1, 1.1, 1.1--1.2, 1.2--1.3, 1.3--1.4 | +| `sslinfo` | 1.2 | 1.0--1.1, 1.1--1.2, 1.2 | +| `tablefunc` | 1.0 | 1.0 | +| `tcn` | 1.0 | 1.0 | +| `tsm_system_rows` | 1.0 | 1.0 | +| `tsm_system_time` | 1.0 | 1.0 | +| `unaccent` | 1.1 | 1.0--1.1, 1.1 | +| `uuid-ossp` | 1.1 | 1.0--1.1, 1.1 | +| `xml2` | 1.1 | 1.0--1.1, 1.1 | + +Third-party extensions such as `pgvector`, `pg_tle`, or `pgmq` are not packaged +with the official binaries yet. We plan to allow registering additional +extension manifests in the future so these ecosystems can be supported once the +project provides a portable installation workflow for them. + +### Experimental PGXN workflow + +The repository bundles a helper for developers who want to experiment with +community extensions compiled from source. After installing the +[`pgxnclient`](https://pgxn.github.io/pgxnclient/) CLI, the script resolves the +TinyPG toolchain and invokes `pgxn install` with the correct `pg_config` +binary:: + + python scripts/pgxn_install_extension.py pgvector + +At the moment the portable PostgreSQL builds bundled with TinyPG do not expose +the `pg_config` utility that PGXN requires, so the helper exits with a clear +error explaining that third-party compilation is not yet possible. This script +still serves as a reference point for the command sequence and will succeed once +the toolchain includes `pg_config` in a future release. + +Building extensions requires a standard C compiler toolchain, development +headers, and network access to fetch dependency archives. These prerequisites +are available on most Linux distributions. The process is currently unsupported +on Windows and may require additional setup on macOS depending on your Xcode +installation. Even when the command succeeds, the extension binaries become +part of the shared TinyPG PostgreSQL distribution, so coordinate installations +carefully if multiple test runs share the same cached archive. + ## Documentation / API Reference TinyPG's documentation is available there: diff --git a/scripts/pgxn_install_extension.py b/scripts/pgxn_install_extension.py new file mode 100755 index 0000000..1a6e7f7 --- /dev/null +++ b/scripts/pgxn_install_extension.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Install a PostgreSQL extension from PGXN for the bundled TinyPG binaries.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_PATH = PROJECT_ROOT / "src" +if str(SRC_PATH) not in sys.path: + sys.path.insert(0, str(SRC_PATH)) + +from tinypg.binaries import PostgreSQLBinaries +from tinypg.config import TinyPGConfig +from tinypg.exceptions import BinaryNotFoundError + + +def install_extension(extension: str, *, quiet: bool = False) -> int: + """Run ``pgxn install`` for ``extension`` using TinyPG's ``pg_config``.""" + + pgxn = shutil.which("pgxn") + if pgxn is None: + raise RuntimeError( + "pgxn executable not found. Install pgxnclient (pip install pgxnclient)." + ) + + version = TinyPGConfig.default_version + PostgreSQLBinaries.ensure_version(version) + + try: + pg_config = PostgreSQLBinaries.get_binary_path("pg_config", version) + except BinaryNotFoundError as exc: + raise RuntimeError( + "TinyPG's bundled PostgreSQL does not expose pg_config; third-party " + "extensions cannot currently be compiled against it." + ) from exc + if pg_config is None: + raise RuntimeError("Unable to locate pg_config from TinyPG binaries") + + cmd = [ + pgxn, + "install", + "--pg_config", + str(pg_config), + extension, + ] + + completed = subprocess.run( + cmd, + stdout=subprocess.PIPE if quiet else None, + stderr=subprocess.PIPE if quiet else None, + text=True, + check=False, + ) + + if quiet and completed.stdout: + sys.stdout.write(completed.stdout) + if quiet and completed.stderr: + sys.stderr.write(completed.stderr) + + return completed.returncode + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "extension", + metavar="EXTENSION", + help="Name of the extension to install via pgxn", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="Suppress pgxn output until the command finishes", + ) + args = parser.parse_args(argv) + + return install_extension(args.extension, quiet=args.quiet) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/tinypg/__init__.py b/src/tinypg/__init__.py index 1f704e7..11eb498 100644 --- a/src/tinypg/__init__.py +++ b/src/tinypg/__init__.py @@ -76,12 +76,22 @@ DownloadError, TinyPGError, ) +from .extensions import ( + ExtensionManifest, + ExtensionSpec, + get_available_extension, + list_available_extensions, +) __version__ = "0.1.0" __all__ = [ "EphemeralDB", "AsyncEphemeralDB", + "ExtensionSpec", + "ExtensionManifest", + "get_available_extension", + "list_available_extensions", "database", "async_database", "database_pool", diff --git a/src/tinypg/binaries.py b/src/tinypg/binaries.py index 79872df..2148ced 100644 --- a/src/tinypg/binaries.py +++ b/src/tinypg/binaries.py @@ -11,12 +11,13 @@ import tempfile import zipfile from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple import requests from .config import TinyPGConfig from .exceptions import BinaryNotFoundError, DownloadError, ProcessError +from .extensions import ExtensionManifest class PostgreSQLBinaries: @@ -164,6 +165,41 @@ def list_available_versions(cls) -> List[str]: return versions + @classmethod + def list_extension_manifests( + cls, version: Optional[str] = None + ) -> Dict[str, ExtensionManifest]: + """List extension manifests available for an installed PostgreSQL version.""" + + version = version or TinyPGConfig.default_version + install_dir = cls.ensure_version(version) + share_dir_candidates = [ + install_dir / "share" / "extension", + install_dir / "share" / "postgresql" / "extension", + ] + + share_dir = next((path for path in share_dir_candidates if path.exists()), None) + + if share_dir is None: + return {} + + manifests: Dict[str, ExtensionManifest] = {} + + for control_path in sorted(share_dir.glob("*.control")): + manifest = cls._build_extension_manifest(install_dir, control_path) + manifests[manifest.name] = manifest + + return manifests + + @classmethod + def get_extension_manifest( + cls, name: str, version: Optional[str] = None + ) -> Optional[ExtensionManifest]: + """Return the manifest for a specific extension if available.""" + + manifests = cls.list_extension_manifests(version=version) + return manifests.get(name) + def download_postgresql(self, version: str, force: bool = False) -> Path: """ Download and install a specific PostgreSQL version. @@ -371,6 +407,142 @@ def _verify_installation(self, install_dir: Path) -> None: print(f"PostgreSQL installation verified at {install_dir}") + @staticmethod + def _build_extension_manifest( + install_dir: Path, control_path: Path + ) -> ExtensionManifest: + metadata = PostgreSQLBinaries._parse_extension_control(control_path) + name = control_path.stem + requires = PostgreSQLBinaries._coerce_requires(metadata.get("requires")) + relocatable = PostgreSQLBinaries._coerce_bool(metadata.get("relocatable")) + schema = ( + metadata.get("schema") if isinstance(metadata.get("schema"), str) else None + ) + + sql_directory = control_path.parent if control_path.parent.exists() else None + available_versions = PostgreSQLBinaries._discover_extension_versions( + sql_directory, name + ) + + module_pathname = metadata.get("module_pathname") + library_path = PostgreSQLBinaries._resolve_library_path( + install_dir, module_pathname, name + ) + + comment = ( + metadata.get("comment") + if isinstance(metadata.get("comment"), str) + else None + ) + default_version = ( + metadata.get("default_version") + if isinstance(metadata.get("default_version"), str) + else None + ) + + return ExtensionManifest( + name=name, + default_version=default_version, + comment=comment, + relocatable=relocatable, + requires=requires, + control_path=control_path, + sql_directory=sql_directory, + library_path=library_path, + available_versions=available_versions, + schema=schema, + ) + + @staticmethod + def _parse_extension_control(control_path: Path) -> Dict[str, object]: + metadata: Dict[str, object] = {} + + with control_path.open("r", encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.strip() + + if not line or line.startswith("#"): + continue + + if "=" not in line: + continue + + key, raw_value = [part.strip() for part in line.split("=", 1)] + metadata[key] = PostgreSQLBinaries._normalize_control_value(raw_value) + + return metadata + + @staticmethod + def _normalize_control_value(raw_value: str) -> object: + value = raw_value + + if value.startswith("'") and value.endswith("'"): + value = value[1:-1] + + lowered = value.lower() + if lowered == "true": + return True + if lowered == "false": + return False + + return value + + @staticmethod + def _coerce_bool(value: object) -> Optional[bool]: + if isinstance(value, bool): + return value + return None + + @staticmethod + def _coerce_requires(value: object) -> Tuple[str, ...]: + if isinstance(value, str): + parts = [part.strip() for part in value.split(",")] + return tuple(sorted(part for part in parts if part)) + return () + + @staticmethod + def _discover_extension_versions( + sql_directory: Optional[Path], name: str + ) -> Tuple[str, ...]: + if not sql_directory or not sql_directory.exists(): + return () + + versions = set() + + for sql_file in sql_directory.glob(f"{name}--*.sql"): + stem = sql_file.stem + if "--" not in stem: + continue + versions.add(stem.split("--", 1)[1]) + + return tuple(sorted(versions)) + + @staticmethod + def _resolve_library_path( + install_dir: Path, module_pathname: Optional[object], name: str + ) -> Optional[Path]: + libdir = install_dir / "lib" / "postgresql" + + if not libdir.exists(): + return None + + if isinstance(module_pathname, str): + path = module_pathname.strip() + + if path.startswith("$libdir"): + relative = path[len("$libdir") :].lstrip("/") + candidate = libdir / (relative or f"{name}.so") + else: + candidate = Path(path) + if not candidate.is_absolute(): + candidate = (install_dir / candidate).resolve() + + if candidate.exists(): + return candidate + + candidate = libdir / f"{name}.so" + return candidate if candidate.exists() else None + # Legacy class for compatibility class PostgreSQLBinaryManager(PostgreSQLBinaries): diff --git a/src/tinypg/context.py b/src/tinypg/context.py index 967e8bf..b243105 100644 --- a/src/tinypg/context.py +++ b/src/tinypg/context.py @@ -2,9 +2,10 @@ import asyncio from contextlib import asynccontextmanager, contextmanager -from typing import AsyncContextManager, ContextManager, List, Optional +from typing import AsyncContextManager, ContextManager, Dict, List, Optional, Sequence from .core import AsyncEphemeralDB, EphemeralDB +from .extensions import ExtensionInput @contextmanager @@ -14,6 +15,7 @@ def database( postgres_args: Optional[List[str]] = None, version: str = None, keep_data: bool = False, + extensions: Optional[Sequence[ExtensionInput]] = None, ) -> ContextManager[str]: """Yield a temporary PostgreSQL database URI. @@ -28,6 +30,9 @@ def database( ``tinypg.config.TinyPGConfig`` value when ``None``. keep_data: When ``True`` the data directory is preserved after the database stops. Useful for debugging failed test runs. + extensions: Optional collection of extensions to install immediately + after the server starts. Entries can be provided in any format + accepted by :class:`tinypg.ExtensionSpec`. Yields: str: PostgreSQL connection URI for the running database instance. @@ -48,6 +53,7 @@ def database( postgres_args=postgres_args, version=version, keep_data=keep_data, + extensions=extensions, ) try: @@ -64,6 +70,7 @@ async def async_database( postgres_args: Optional[List[str]] = None, version: str = None, keep_data: bool = False, + extensions: Optional[Sequence[ExtensionInput]] = None, ) -> AsyncContextManager[str]: """Asynchronously yield a temporary PostgreSQL database URI. @@ -78,6 +85,9 @@ async def async_database( ``tinypg.config.TinyPGConfig`` value when ``None``. keep_data: When ``True`` the data directory is preserved after the database stops. Useful for debugging failed test runs. + extensions: Optional collection of extensions to install immediately + after the server starts. Entries can be provided in any format + accepted by :class:`tinypg.ExtensionSpec`. Yields: str: PostgreSQL connection URI for the running database instance. @@ -98,6 +108,7 @@ async def async_database( postgres_args=postgres_args, version=version, keep_data=keep_data, + extensions=extensions, ) try: @@ -113,6 +124,7 @@ def database_pool( timeout: int = 60, version: str = None, base_port: Optional[int] = None, + extensions: Optional[Sequence[ExtensionInput]] = None, ) -> ContextManager[List[str]]: """Create a pool of independent PostgreSQL databases. @@ -124,6 +136,9 @@ def database_pool( ``tinypg.config.TinyPGConfig`` value when ``None``. base_port: Base port number. When provided, ports are allocated as ``base_port + i``. + extensions: Optional collection of extensions to install immediately + after each server starts. Entries can be provided in any format + accepted by :class:`tinypg.ExtensionSpec`. Yields: list[str]: Connection URIs for the running databases. @@ -150,6 +165,7 @@ def database_pool( port=port, cleanup_timeout=timeout, version=version, + extensions=extensions, ) uri = db.start() @@ -174,6 +190,7 @@ async def async_database_pool( timeout: int = 60, version: str = None, base_port: Optional[int] = None, + extensions: Optional[Sequence[ExtensionInput]] = None, ) -> AsyncContextManager[List[str]]: """Asynchronously create a pool of independent PostgreSQL databases. @@ -185,6 +202,9 @@ async def async_database_pool( ``tinypg.config.TinyPGConfig`` value when ``None``. base_port: Base port number. When provided, ports are allocated as ``base_port + i``. + extensions: Optional collection of extensions to install immediately + after each server starts. Entries can be provided in any format + accepted by :class:`tinypg.ExtensionSpec`. Yields: list[str]: Connection URIs for the running databases. diff --git a/src/tinypg/core.py b/src/tinypg/core.py index ad34526..fe6d17e 100644 --- a/src/tinypg/core.py +++ b/src/tinypg/core.py @@ -1,6 +1,4 @@ -""" -Core ephemeral database implementation. -""" +"""Core ephemeral database implementation.""" import asyncio import getpass @@ -11,7 +9,10 @@ import tempfile import time from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Sequence, Union + +if TYPE_CHECKING: + from psycopg2.sql import Composable from urllib.parse import quote from .binaries import PostgreSQLBinaries @@ -22,6 +23,7 @@ InitDBError, ProcessError, ) +from .extensions import ExtensionInput, ExtensionManifest, ExtensionSpec from .port_manager import get_free_port @@ -36,6 +38,7 @@ def __init__( data_dir: Optional[Path] = None, version: str = None, keep_data: bool = False, + extensions: Optional[Sequence[ExtensionInput]] = None, ) -> None: """ Create an ephemeral PostgreSQL database. @@ -47,12 +50,18 @@ def __init__( data_dir: Custom data directory (temp dir if None) version: PostgreSQL version to use (uses default if None) keep_data: Keep data directory after stopping (for debugging) + extensions: Extensions to install after the server starts. Items + can be provided as strings (extension name), mappings with + optional ``schema``, ``version`` and ``cascade`` keys, or + :class:`tinypg.ExtensionSpec`/:class:`tinypg.ExtensionManifest` + instances. """ self.port = port self.cleanup_timeout = cleanup_timeout self.postgres_args = postgres_args or [] self.version = version or TinyPGConfig.default_version self.keep_data = keep_data + self._extensions = self._normalize_extensions(extensions) # Runtime state self._data_dir: Optional[Path] = data_dir @@ -90,12 +99,15 @@ def start(self) -> str: # Start PostgreSQL server self._start_postgres_server() + self._is_running = True + + # Install requested extensions + self._install_extensions() + # Set up automatic cleanup if self.cleanup_timeout > 0: self._setup_cleanup() - self._is_running = True - # Cache connection info self._connection_info = self._build_connection_info() @@ -162,7 +174,7 @@ def get_connection_info(self) -> Dict[str, Any]: return self._connection_info - def execute_sql(self, sql: str) -> None: + def execute_sql(self, statement: Union[str, "Composable"]) -> None: """Execute SQL directly on the database.""" if not self._is_running: raise DatabaseStartError("Database is not running") @@ -189,13 +201,48 @@ def execute_sql(self, sql: str) -> None: conn.autocommit = True with conn.cursor() as cur: - cur.execute(sql) + cur.execute(statement) conn.close() except psycopg2.Error as e: raise ProcessError(f"Failed to execute SQL: {e}") + def install_extension(self, extension: ExtensionInput) -> None: + """Install a PostgreSQL extension on the running database.""" + + if not self._is_running: + raise DatabaseStartError("Database is not running") + + spec = ExtensionSpec.from_value(extension) + self.execute_sql(spec.to_sql()) + + def create_extension(self, extension: ExtensionInput) -> None: + """Alias for :meth:`install_extension` to mirror SQL semantics.""" + + self.install_extension(extension) + + def install_extensions(self, extensions: Iterable[ExtensionInput]) -> None: + """Install multiple PostgreSQL extensions on the running database.""" + + for extension in extensions: + self.install_extension(extension) + + def _normalize_extensions( + self, extensions: Optional[Sequence[ExtensionInput]] + ) -> List[ExtensionSpec]: + """Normalize extension specifications provided by the user.""" + + if not extensions: + return [] + + normalized: List[ExtensionSpec] = [] + + for extension in extensions: + normalized.append(ExtensionSpec.from_value(extension)) + + return normalized + def load_sql_file(self, file_path: Path) -> None: """Load and execute SQL from a file.""" if not file_path.exists(): @@ -265,6 +312,15 @@ def _configure_postgresql(self, data_dir: Path) -> None: with open(config_file, "a") as f: f.write(config_additions) + def _install_extensions(self) -> None: + """Install user-requested extensions on the running database.""" + + if not self._extensions: + return + + for extension in self._extensions: + self.execute_sql(extension.to_sql()) + def _start_postgres_server(self) -> None: """Start the PostgreSQL server process.""" try: @@ -395,10 +451,27 @@ async def stop(self) -> None: loop = asyncio.get_event_loop() await loop.run_in_executor(None, super().stop) - async def execute_sql(self, sql: str) -> None: + async def execute_sql(self, statement: Union[str, "Composable"]) -> None: """Execute SQL asynchronously.""" loop = asyncio.get_event_loop() - await loop.run_in_executor(None, super().execute_sql, sql) + await loop.run_in_executor(None, super().execute_sql, statement) + + async def install_extension(self, extension: ExtensionInput) -> None: + """Install a PostgreSQL extension asynchronously.""" + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, super().install_extension, extension) + + async def create_extension(self, extension: ExtensionInput) -> None: + """Async alias for :meth:`install_extension`.""" + + await self.install_extension(extension) + + async def install_extensions(self, extensions: Iterable[ExtensionInput]) -> None: + """Install multiple PostgreSQL extensions asynchronously.""" + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, super().install_extensions, extensions) async def __aenter__(self): """Async context manager entry.""" diff --git a/src/tinypg/docs/__init__.py b/src/tinypg/docs/__init__.py new file mode 100644 index 0000000..7d65ec4 --- /dev/null +++ b/src/tinypg/docs/__init__.py @@ -0,0 +1 @@ +"""Additional narrative documentation for TinyPG's pdoc site.""" diff --git a/src/tinypg/docs/extensions.py b/src/tinypg/docs/extensions.py new file mode 100644 index 0000000..9a774ba --- /dev/null +++ b/src/tinypg/docs/extensions.py @@ -0,0 +1,107 @@ +"""Using PostgreSQL extensions +================================= + +This narrative guide expands on :mod:`tinypg.extensions` and walks through the +practical steps required to enable PostgreSQL extensions inside an ephemeral +database provisioned by TinyPG. + +Declaring extensions +-------------------- + +You can request extensions when creating a database through any of TinyPG's +context managers or helpers. The ``extensions`` parameter accepts strings, +``ExtensionSpec`` instances, or :class:`tinypg.extensions.ExtensionManifest` +objects. The examples below demonstrate each option:: + + import tinypg + + # Simple string input + with tinypg.database(extensions=["pgcrypto"]): + ... + + # Rich specification with cascade support + from tinypg import ExtensionSpec + + spec = ExtensionSpec(name="pg_trgm", schema="public", cascade=True) + with tinypg.database(extensions=[spec]): + ... + + # Manifests can be turned into specs + manifest = tinypg.get_available_extension("hstore") + if manifest: + with tinypg.database(extensions=[manifest]): + ... + +Discovering bundled extensions +------------------------------- + +TinyPG parses the ``.control`` files that ship with the portable PostgreSQL +distribution during installation. The :func:`tinypg.list_available_extensions` +function returns a dictionary mapping extension names to +:class:`tinypg.extensions.ExtensionManifest` instances, each providing details +about default and upgradeable versions, dependencies, and associated SQL or +shared library files. Use it to present feature flags in your application or to +perform guard checks before requesting an installation. + +Roadmap for additional ecosystems +--------------------------------- + +Only the extensions bundled with PostgreSQL itself are currently available. +Popular third-party projects such as ``pgvector``, ``pg_tle``, or ``pgmq`` +require additional build steps and are not yet supported. The manifest APIs were +designed with these future workflows in mind, and TinyPG will grow the ability +to register custom manifests once the project offers a portable installation +story for community-maintained extensions. + +Installing additional extensions with PGXN +------------------------------------------ + +Advanced users can experiment with third-party packages by compiling them +against the PostgreSQL toolchain that TinyPG downloads. The +``PostgreSQLBinaries.get_binary_path`` helper exposes the path to ``pg_config``, +which most build tools use to locate headers and libraries:: + + from tinypg.binaries import PostgreSQLBinaries + + pg_config_path = PostgreSQLBinaries.get_binary_path("pg_config") + +That path can be passed to command line installers such as +`PGXN `_ by spawning a subprocess from Python. The +repository ships with a small helper that wires everything together:: + + python scripts/pgxn_install_extension.py pgvector + +Under the hood the script resolves TinyPG's ``pg_config`` executable and feeds +it to ``pgxn install``. You can mimic the behaviour manually if you prefer:: + + import subprocess + + subprocess.run( + [ + "pgxn", + "install", + "--pg_config", + str(pg_config_path), + "pgvector", + ], + check=True, + ) + +The current TinyPG binaries mirror ``pg-embed``'s stripped-down toolchain and do +not ship ``pg_config`` yet. Running the helper therefore raises a descriptive +error instead of installing the requested extension. The command sequence is +still useful for future releases or for developers who rebuild PostgreSQL with +the full client utilities available. + +The ``pgxn`` client supports ``--pg_config`` out of the box, but keep in mind +that it will modify the PostgreSQL installation referenced by that executable. +If you manage multiple TinyPG clusters simultaneously, install additional +extensions before the databases start or coordinate the installations carefully +to avoid races. + +Building extensions typically requires a C compiler toolchain and standard +development headers. These are available on most Linux distributions and on +macOS with the Xcode command line tools. Windows support is not yet available +for TinyPG, and PGXN installations on that platform may require significant +manual setup. +""" diff --git a/src/tinypg/extensions.py b/src/tinypg/extensions.py new file mode 100644 index 0000000..bcfa8c8 --- /dev/null +++ b/src/tinypg/extensions.py @@ -0,0 +1,159 @@ +"""Helpers for working with PostgreSQL extensions. + +TinyPG ships with the same portable PostgreSQL builds that power the +``pg-embed`` project. Those archives bundle more than sixty standard extensions +and this module provides the discovery and installation helpers that surface +them to application code. + +The :class:`ExtensionSpec` dataclass is a normalized representation of the +``CREATE EXTENSION`` command that TinyPG understands. It accepts either strings, +mapping objects, or :class:`ExtensionManifest` instances and renders safe SQL +using psycopg2 composables:: + + from tinypg import database, ExtensionSpec + + with database(extensions=["pgcrypto", ExtensionSpec(name="pg_trgm")]): + ... # both extensions are installed before yielding the DSN + +Two convenience helpers make it easy to inspect the bundled catalog at runtime: + +``list_available_extensions()`` + Returns a dictionary of :class:`ExtensionManifest` objects keyed by name. + +``get_available_extension(name)`` + Looks up a single :class:`ExtensionManifest` by name and returns ``None`` if + the extension is not shipped with the selected PostgreSQL version. + +TinyPG currently exposes the extensions provided by the upstream PostgreSQL +distribution. Third-party projects such as ``pgvector``, ``pg_tle``, or +``pgmq`` are not yet packaged with TinyPG, but future releases will allow +registering custom manifests so those ecosystems can be supported. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple, Union + + +@dataclass(frozen=True) +class ExtensionSpec: + """Normalized description of a PostgreSQL extension.""" + + name: str + schema: Optional[str] = None + version: Optional[str] = None + cascade: bool = False + + @classmethod + def from_value(cls, value: "ExtensionInput") -> "ExtensionSpec": + """Create an :class:`ExtensionSpec` from user input.""" + + if isinstance(value, cls): + return value + + if isinstance(value, ExtensionManifest): + return value.to_spec() + + if isinstance(value, str): + return cls(name=value) + + if isinstance(value, dict): + if "name" not in value or not value["name"]: + raise ValueError("Extension specification requires a non-empty 'name'.") + + return cls( + name=value["name"], + schema=value.get("schema"), + version=value.get("version"), + cascade=bool(value.get("cascade", False)), + ) + + raise TypeError( + "Extension specification must be a string, mapping, or ExtensionSpec instance." + ) + + def to_sql(self): + """Render a safe ``CREATE EXTENSION`` SQL statement.""" + + from psycopg2 import sql + + statement = sql.SQL("CREATE EXTENSION IF NOT EXISTS {}").format( + sql.Identifier(self.name) + ) + + if self.schema: + statement += sql.SQL(" SCHEMA {}").format(sql.Identifier(self.schema)) + + if self.version: + statement += sql.SQL(" VERSION {}").format(sql.Literal(self.version)) + + if self.cascade: + statement += sql.SQL(" CASCADE") + + return statement + + +@dataclass(frozen=True) +class ExtensionManifest: + """Metadata about an available PostgreSQL extension.""" + + name: str + default_version: Optional[str] = None + comment: Optional[str] = None + relocatable: Optional[bool] = None + requires: Tuple[str, ...] = () + control_path: Optional[Path] = None + sql_directory: Optional[Path] = None + library_path: Optional[Path] = None + available_versions: Tuple[str, ...] = () + schema: Optional[str] = None + + def to_spec( + self, + *, + version: Optional[str] = None, + schema: Optional[str] = None, + cascade: bool = False, + ) -> ExtensionSpec: + """Create an :class:`ExtensionSpec` targeting this manifest.""" + + return ExtensionSpec( + name=self.name, + schema=schema or self.schema, + version=version or self.default_version, + cascade=cascade, + ) + + +ExtensionInput = Union[ExtensionManifest, ExtensionSpec, str, Dict[str, object]] + + +def list_available_extensions( + version: Optional[str] = None, +) -> Dict[str, ExtensionManifest]: + """Return manifests for extensions available in the bundled binaries.""" + + from .binaries import PostgreSQLBinaries + + return PostgreSQLBinaries.list_extension_manifests(version=version) + + +def get_available_extension( + name: str, version: Optional[str] = None +) -> Optional[ExtensionManifest]: + """Return the manifest for ``name`` if the extension is bundled.""" + + from .binaries import PostgreSQLBinaries + + return PostgreSQLBinaries.get_extension_manifest(name=name, version=version) + + +__all__ = [ + "ExtensionInput", + "ExtensionManifest", + "ExtensionSpec", + "get_available_extension", + "list_available_extensions", +] diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 0000000..4d24642 --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,102 @@ +"""Tests covering TinyPG extension discovery and installation.""" + +import psycopg2 +from psycopg2 import sql + +from tinypg import ( + EphemeralDB, + ExtensionManifest, + ExtensionSpec, + get_available_extension, + list_available_extensions, +) + + +def test_extension_installation_on_startup(): + """Extensions requested at construction are installed automatically.""" + + db = EphemeralDB(cleanup_timeout=0, extensions=["pgcrypto"]) + + conn = None + + try: + db.start() + conn = psycopg2.connect(db.get_connection_info()["uri"]) + + with conn.cursor() as cur: + cur.execute("SELECT extname FROM pg_extension WHERE extname = 'pgcrypto'") + assert cur.fetchone()[0] == "pgcrypto" + finally: + if conn is not None: + conn.close() + db.stop() + + +def test_extension_installation_after_start(): + """Extensions can be installed after the database started.""" + + db = EphemeralDB(cleanup_timeout=0) + + conn = None + + try: + db.start() + manifest = get_available_extension("pgcrypto") + assert isinstance(manifest, ExtensionManifest) + + db.create_extension(manifest) + + conn = psycopg2.connect(db.get_connection_info()["uri"]) + + with conn.cursor() as cur: + cur.execute("SELECT extname FROM pg_extension WHERE extname = 'pgcrypto'") + assert cur.fetchone()[0] == "pgcrypto" + finally: + if conn is not None: + conn.close() + db.stop() + + +def test_extension_spec_sql_composition_is_safe(): + """Extension specifications produce safely quoted SQL statements.""" + + spec = ExtensionSpec( + name='pgcrypto"; DROP SCHEMA public; --', + schema="dangerous schema", + version="1.2.3'; DROP TABLE pg_catalog.pg_class; --", + cascade=True, + ) + + statement = spec.to_sql() + + expected = sql.SQL("CREATE EXTENSION IF NOT EXISTS {}").format( + sql.Identifier('pgcrypto"; DROP SCHEMA public; --') + ) + expected += sql.SQL(" SCHEMA {}").format(sql.Identifier("dangerous schema")) + expected += sql.SQL(" VERSION {}").format( + sql.Literal("1.2.3'; DROP TABLE pg_catalog.pg_class; --") + ) + expected += sql.SQL(" CASCADE") + + assert repr(statement) == repr(expected) + assert isinstance(statement, sql.Composed) + + +def test_list_available_extensions_discovers_pgcrypto(): + """Bundled binaries include common extensions such as pgcrypto.""" + + manifests = list_available_extensions() + assert "pgcrypto" in manifests + + manifest = manifests["pgcrypto"] + assert manifest.default_version is not None + assert manifest.library_path is None or manifest.library_path.exists() + assert "pgcrypto" in manifest.control_path.name + + +def test_get_available_extension_returns_manifest(): + """Manifests can be fetched individually by name.""" + + manifest = get_available_extension("pgcrypto") + assert isinstance(manifest, ExtensionManifest) + assert "pgcrypto" in manifest.available_versions or manifest.default_version From c3d10f16b3f96ab52945785e5632a6ea532a784e Mon Sep 17 00:00:00 2001 From: Kevin Wenner Date: Sat, 27 Sep 2025 09:57:40 +0000 Subject: [PATCH 2/5] add osx to ci remove pgxn test script --- .github/workflows/ci.yml | 22 +++++++- .readthedocs.yaml | 2 +- README.md | 4 +- pyproject.toml | 1 + scripts/pgxn_install_extension.py | 86 ------------------------------- 5 files changed, 25 insertions(+), 90 deletions(-) delete mode 100755 scripts/pgxn_install_extension.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3bc149..be1b8af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,4 +39,24 @@ jobs: - name: Check formatting run: | uv run --python ${{ matrix.python-version }} black --check . - uv run --python ${{ matrix.python-version }} isort --check-only . \ No newline at end of file + uv run --python ${{ matrix.python-version }} isort --check-only . + + test-osx: + name: macOS Python 3.12 + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python 3.12 + run: uv python install 3.12 + + - name: Install the project + run: uv sync --locked --all-extras --dev + + - name: Run tests + run: uv run --python 3.12 pytest \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f8bcc10..7c38cfc 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,4 +6,4 @@ build: python: "3.12" commands: - pip install .[docs] - - python -m pdoc --docformat google --output-directory $READTHEDOCS_OUTPUT/html tinypg \ No newline at end of file + - python -m pdoc --docformat google --output-directory $READTHEDOCS_OUTPUT/html --logo https://iili.io/Klv1Zcx.md.png tinypg \ No newline at end of file diff --git a/README.md b/README.md index 9e137e1..38bcc6b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![Klv1Zcx.md.png](https://iili.io/Klv1Zcx.md.png)](https://freeimage.host/i/Klv1Zcx) +![logo](https://iili.io/Klv1Zcx.md.png) # TinyPG @@ -9,7 +9,7 @@ A Python package for creating ephemeral PostgreSQL databases, inspired by [ephem TinyPG provides a clean Python API for creating temporary PostgreSQL databases for development and testing. It's designed to be self-contained and work without requiring system-wide PostgreSQL installation. -**Currently only tested on linux, but should work on OSX and Windows hopefully** +**Currently only tested on linux & osx. Does not work on Windows yet.** ## Features diff --git a/pyproject.toml b/pyproject.toml index ff33e8c..78f8801 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", diff --git a/scripts/pgxn_install_extension.py b/scripts/pgxn_install_extension.py deleted file mode 100755 index 1a6e7f7..0000000 --- a/scripts/pgxn_install_extension.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -"""Install a PostgreSQL extension from PGXN for the bundled TinyPG binaries.""" - -from __future__ import annotations - -import argparse -import shutil -import subprocess -import sys -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -SRC_PATH = PROJECT_ROOT / "src" -if str(SRC_PATH) not in sys.path: - sys.path.insert(0, str(SRC_PATH)) - -from tinypg.binaries import PostgreSQLBinaries -from tinypg.config import TinyPGConfig -from tinypg.exceptions import BinaryNotFoundError - - -def install_extension(extension: str, *, quiet: bool = False) -> int: - """Run ``pgxn install`` for ``extension`` using TinyPG's ``pg_config``.""" - - pgxn = shutil.which("pgxn") - if pgxn is None: - raise RuntimeError( - "pgxn executable not found. Install pgxnclient (pip install pgxnclient)." - ) - - version = TinyPGConfig.default_version - PostgreSQLBinaries.ensure_version(version) - - try: - pg_config = PostgreSQLBinaries.get_binary_path("pg_config", version) - except BinaryNotFoundError as exc: - raise RuntimeError( - "TinyPG's bundled PostgreSQL does not expose pg_config; third-party " - "extensions cannot currently be compiled against it." - ) from exc - if pg_config is None: - raise RuntimeError("Unable to locate pg_config from TinyPG binaries") - - cmd = [ - pgxn, - "install", - "--pg_config", - str(pg_config), - extension, - ] - - completed = subprocess.run( - cmd, - stdout=subprocess.PIPE if quiet else None, - stderr=subprocess.PIPE if quiet else None, - text=True, - check=False, - ) - - if quiet and completed.stdout: - sys.stdout.write(completed.stdout) - if quiet and completed.stderr: - sys.stderr.write(completed.stderr) - - return completed.returncode - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "extension", - metavar="EXTENSION", - help="Name of the extension to install via pgxn", - ) - parser.add_argument( - "--quiet", - action="store_true", - help="Suppress pgxn output until the command finishes", - ) - args = parser.parse_args(argv) - - return install_extension(args.extension, quiet=args.quiet) - - -if __name__ == "__main__": - raise SystemExit(main()) From 42323c9d051c07bce95ffeeed5553eb792792d8e Mon Sep 17 00:00:00 2001 From: Kevin Wenner Date: Sat, 27 Sep 2025 09:58:58 +0000 Subject: [PATCH 3/5] update package version --- pyproject.toml | 2 +- src/tinypg/__init__.py | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 78f8801..6d15e02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tinypg" -version = "0.1.0" +version = "0.2.0" description = "Ephemeral PostgreSQL databases for Python development and testing" readme = "README.md" license = {text = "MIT"} diff --git a/src/tinypg/__init__.py b/src/tinypg/__init__.py index 11eb498..44245ac 100644 --- a/src/tinypg/__init__.py +++ b/src/tinypg/__init__.py @@ -83,7 +83,7 @@ list_available_extensions, ) -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "EphemeralDB", diff --git a/uv.lock b/uv.lock index bd48f08..66e367a 100644 --- a/uv.lock +++ b/uv.lock @@ -920,7 +920,7 @@ wheels = [ [[package]] name = "tinypg" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "psycopg2-binary" }, From c102344bc086f0d2e1294c5df74d32a712cbd467 Mon Sep 17 00:00:00 2001 From: Kevin Wenner Date: Sat, 27 Sep 2025 10:04:04 +0000 Subject: [PATCH 4/5] update docs --- README.md | 22 ++++------ src/tinypg/__init__.py | 93 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 38bcc6b..88a0660 100644 --- a/README.md +++ b/README.md @@ -157,19 +157,13 @@ default PostgreSQL 15 bundle currently includes the following extensions: | `xml2` | 1.1 | 1.0--1.1, 1.1 | Third-party extensions such as `pgvector`, `pg_tle`, or `pgmq` are not packaged -with the official binaries yet. We plan to allow registering additional -extension manifests in the future so these ecosystems can be supported once the +with the official binaries yet. Adding additional +extension in the future could be possible so these ecosystems can be supported once the project provides a portable installation workflow for them. -### Experimental PGXN workflow +### Can I use other postgres extensions? -The repository bundles a helper for developers who want to experiment with -community extensions compiled from source. After installing the -[`pgxnclient`](https://pgxn.github.io/pgxnclient/) CLI, the script resolves the -TinyPG toolchain and invokes `pgxn install` with the correct `pg_config` -binary:: - - python scripts/pgxn_install_extension.py pgvector +Not yet no. It's possible but it isn't supported yet. At the moment the portable PostgreSQL builds bundled with TinyPG do not expose the `pg_config` utility that PGXN requires, so the helper exits with a clear @@ -179,11 +173,9 @@ the toolchain includes `pg_config` in a future release. Building extensions requires a standard C compiler toolchain, development headers, and network access to fetch dependency archives. These prerequisites -are available on most Linux distributions. The process is currently unsupported -on Windows and may require additional setup on macOS depending on your Xcode -installation. Even when the command succeeds, the extension binaries become -part of the shared TinyPG PostgreSQL distribution, so coordinate installations -carefully if multiple test runs share the same cached archive. +are available on most Linux distributions. This process is currently unsupported +as the trimmed down postgres distribution tinypg uses does not have pg_config +or postgres dev headers avaialble. ## Documentation / API Reference diff --git a/src/tinypg/__init__.py b/src/tinypg/__init__.py index 44245ac..cb22a85 100644 --- a/src/tinypg/__init__.py +++ b/src/tinypg/__init__.py @@ -54,6 +54,99 @@ db.stop() ``` +## Extensions + +TinyPG downloads the same portable PostgreSQL builds that ship with the +`pg-embed` project and exposes metadata about every extension included with the +distribution. Use :func:`tinypg.list_available_extensions` or +:func:`tinypg.get_available_extension` to inspect this catalog at runtime. The +default PostgreSQL 15 bundle currently includes the following extensions: + +| Extension | Default version | Available versions | +| --- | --- | --- | +| `adminpack` | 2.1 | 1.0, 1.0--1.1, 1.1--2.0, 2.0--2.1 | +| `amcheck` | 1.3 | 1.0, 1.0--1.1, 1.1--1.2, 1.2--1.3 | +| `autoinc` | 1.0 | 1.0 | +| `bloom` | 1.0 | 1.0 | +| `bool_plperl` | 1.0 | 1.0 | +| `bool_plperlu` | 1.0 | 1.0 | +| `btree_gin` | 1.3 | 1.0, 1.0--1.1, 1.1--1.2, 1.2--1.3 | +| `btree_gist` | 1.7 | 1.0--1.1, 1.1--1.2, 1.2, 1.2--1.3, 1.3--1.4, 1.4--1.5, 1.5--1.6, 1.6--1.7 | +| `citext` | 1.6 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3--1.4, 1.4, 1.4--1.5, 1.5--1.6 | +| `cube` | 1.5 | 1.0--1.1, 1.1--1.2, 1.2, 1.2--1.3, 1.3--1.4, 1.4--1.5 | +| `dblink` | 1.2 | 1.0--1.1, 1.1--1.2, 1.2 | +| `dict_int` | 1.0 | 1.0 | +| `dict_xsyn` | 1.0 | 1.0 | +| `earthdistance` | 1.1 | 1.0--1.1, 1.1 | +| `file_fdw` | 1.0 | 1.0 | +| `fuzzystrmatch` | 1.1 | 1.0--1.1, 1.1 | +| `hstore` | 1.8 | 1.1--1.2, 1.2--1.3, 1.3--1.4, 1.4, 1.4--1.5, 1.5--1.6, 1.6--1.7, 1.7--1.8 | +| `hstore_plperl` | 1.0 | 1.0 | +| `hstore_plperlu` | 1.0 | 1.0 | +| `hstore_plpython3u` | 1.0 | 1.0 | +| `insert_username` | 1.0 | 1.0 | +| `intagg` | 1.1 | 1.0--1.1, 1.1 | +| `intarray` | 1.5 | 1.0--1.1, 1.1--1.2, 1.2, 1.2--1.3, 1.3--1.4, 1.4--1.5 | +| `isn` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `jsonb_plperl` | 1.0 | 1.0 | +| `jsonb_plperlu` | 1.0 | 1.0 | +| `jsonb_plpython3u` | 1.0 | 1.0 | +| `lo` | 1.1 | 1.0--1.1, 1.1 | +| `ltree` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `ltree_plpython3u` | 1.0 | 1.0 | +| `moddatetime` | 1.0 | 1.0 | +| `old_snapshot` | 1.0 | 1.0 | +| `pageinspect` | 1.11 | 1.0--1.1, 1.1--1.2, 1.10--1.11, 1.2--1.3, 1.3--1.4, 1.4--1.5, 1.5, 1.5--1.6, 1.6--1.7, 1.7--1.8, 1.8--1.9, 1.9--1.10 | +| `pg_buffercache` | 1.3 | 1.0--1.1, 1.1--1.2, 1.2, 1.2--1.3 | +| `pg_freespacemap` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `pg_prewarm` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `pg_stat_statements` | 1.10 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3--1.4, 1.4, 1.4--1.5, 1.5--1.6, 1.6--1.7, 1.7--1.8, 1.8--1.9, 1.9--1.10 | +| `pg_surgery` | 1.0 | 1.0 | +| `pg_trgm` | 1.6 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3, 1.3--1.4, 1.4--1.5, 1.5--1.6 | +| `pg_visibility` | 1.2 | 1.0--1.1, 1.1, 1.1--1.2 | +| `pg_walinspect` | 1.0 | 1.0 | +| `pgcrypto` | 1.3 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3 | +| `pgrowlocks` | 1.2 | 1.0--1.1, 1.1--1.2, 1.2 | +| `pgstattuple` | 1.5 | 1.0--1.1, 1.1--1.2, 1.2--1.3, 1.3--1.4, 1.4, 1.4--1.5 | +| `plperl` | 1.0 | 1.0 | +| `plperlu` | 1.0 | 1.0 | +| `plpgsql` | 1.0 | 1.0 | +| `plpython3u` | 1.0 | 1.0 | +| `pltcl` | 1.0 | 1.0 | +| `pltclu` | 1.0 | 1.0 | +| `postgres_fdw` | 1.1 | 1.0, 1.0--1.1 | +| `refint` | 1.0 | 1.0 | +| `seg` | 1.4 | 1.0--1.1, 1.1, 1.1--1.2, 1.2--1.3, 1.3--1.4 | +| `sslinfo` | 1.2 | 1.0--1.1, 1.1--1.2, 1.2 | +| `tablefunc` | 1.0 | 1.0 | +| `tcn` | 1.0 | 1.0 | +| `tsm_system_rows` | 1.0 | 1.0 | +| `tsm_system_time` | 1.0 | 1.0 | +| `unaccent` | 1.1 | 1.0--1.1, 1.1 | +| `uuid-ossp` | 1.1 | 1.0--1.1, 1.1 | +| `xml2` | 1.1 | 1.0--1.1, 1.1 | + +Third-party extensions such as `pgvector`, `pg_tle`, or `pgmq` are not packaged +with the official binaries yet. Adding additional +extension in the future could be possible so these ecosystems can be supported once the +project provides a portable installation workflow for them. + +### Can I use other postgres extensions? + +Not yet no. It's possible but it isn't supported yet. + +At the moment the portable PostgreSQL builds bundled with TinyPG do not expose +the `pg_config` utility that PGXN requires, so the helper exits with a clear +error explaining that third-party compilation is not yet possible. This script +still serves as a reference point for the command sequence and will succeed once +the toolchain includes `pg_config` in a future release. + +Building extensions requires a standard C compiler toolchain, development +headers, and network access to fetch dependency archives. These prerequisites +are available on most Linux distributions. This process is currently unsupported +as the trimmed down postgres distribution tinypg uses does not have pg_config +or postgres dev headers avaialble. + ## Requirements - Python 3.8+ From 373fbccd2f4224bf7dccfde43059a0cd93dd9ebb Mon Sep 17 00:00:00 2001 From: Kevin Wenner Date: Sat, 27 Sep 2025 10:05:23 +0000 Subject: [PATCH 5/5] format --- src/tinypg/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tinypg/__init__.py b/src/tinypg/__init__.py index cb22a85..c2cf4c4 100644 --- a/src/tinypg/__init__.py +++ b/src/tinypg/__init__.py @@ -144,7 +144,7 @@ Building extensions requires a standard C compiler toolchain, development headers, and network access to fetch dependency archives. These prerequisites are available on most Linux distributions. This process is currently unsupported -as the trimmed down postgres distribution tinypg uses does not have pg_config +as the trimmed down postgres distribution tinypg uses does not have pg_config or postgres dev headers avaialble. ## Requirements