v0.146.0
  1from __future__ import annotations
  2
  3# Copyright (c) Kenneth Reitz & individual contributors
  4# All rights reserved.
  5# Redistribution and use in source and binary forms, with or without modification,
  6# are permitted provided that the following conditions are met:
  7#     1. Redistributions of source code must retain the above copyright notice,
  8#        this list of conditions and the following disclaimer.
  9#     2. Redistributions in binary form must reproduce the above copyright
 10#        notice, this list of conditions and the following disclaimer in the
 11#        documentation and/or other materials provided with the distribution.
 12#     3. Neither the name of Plain nor the names of its contributors may be used
 13#        to endorse or promote products derived from this software without
 14#        specific prior written permission.
 15# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 16# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 17# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 18# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
 19# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 20# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 21# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
 22# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 23# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 24# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 25import urllib.parse as urlparse
 26from typing import Any, TypedDict
 27
 28SCHEMES = {"postgres", "postgresql", "pgsql"}
 29
 30
 31class DatabaseConfig(TypedDict, total=False):
 32    HOST: str
 33    DATABASE: str  # Required (validated in _configure_settings)
 34    OPTIONS: dict[str, Any]
 35    PASSWORD: str
 36    PORT: int | None
 37    USER: str
 38
 39
 40# Register database schemes in URLs.
 41for scheme in SCHEMES:
 42    urlparse.uses_netloc.append(scheme)
 43
 44
 45def parse_database_url(url: str) -> DatabaseConfig:
 46    """Parse a database URL into a fully-populated `DatabaseConfig`.
 47
 48    All keys are present; empty values are `""` (strings), `None` (PORT),
 49    or `{}` (OPTIONS) — callers can index without `.get(...)` defaults.
 50    """
 51    spliturl = urlparse.urlsplit(url)
 52
 53    if spliturl.scheme not in SCHEMES:
 54        raise ValueError(
 55            f"No support for '{spliturl.scheme}'. We support: {', '.join(sorted(SCHEMES))}"
 56        )
 57
 58    path = spliturl.path[1:]
 59    query = urlparse.parse_qs(spliturl.query)
 60
 61    # Handle percent-encoded hostnames (e.g. socket paths).
 62    hostname = spliturl.hostname or ""
 63    if "%" in hostname:
 64        # Use netloc to avoid lowercased paths, strip credentials if present.
 65        hostname = spliturl.netloc
 66        if "@" in hostname:
 67            hostname = hostname.rsplit("@", 1)[1]
 68        hostname = urlparse.unquote(hostname)
 69
 70    return {
 71        "DATABASE": urlparse.unquote(path or ""),
 72        "USER": urlparse.unquote(spliturl.username or ""),
 73        "PASSWORD": urlparse.unquote(spliturl.password or ""),
 74        "HOST": hostname,
 75        "PORT": spliturl.port,
 76        "OPTIONS": {key: values[-1] for key, values in query.items()},
 77    }
 78
 79
 80_CLI_FLAGS: list[tuple[str, str]] = [("USER", "-U"), ("HOST", "-h"), ("PORT", "-p")]
 81_CLI_OPTION_ENV_VARS: dict[str, str] = {
 82    "passfile": "PGPASSFILE",
 83    "sslmode": "PGSSLMODE",
 84    "sslrootcert": "PGSSLROOTCERT",
 85    "sslcert": "PGSSLCERT",
 86    "sslkey": "PGSSLKEY",
 87}
 88
 89
 90def postgres_cli_args(config: DatabaseConfig) -> list[str]:
 91    """Build connection flags for libpq-based tools (psql, pg_dump, pg_restore)."""
 92    args: list[str] = []
 93    for key, flag in _CLI_FLAGS:
 94        if value := config.get(key):
 95            args += [flag, str(value)]
 96    return args
 97
 98
 99def postgres_cli_env(config: DatabaseConfig) -> dict[str, str]:
100    """Build env vars for libpq-based tools (psql, pg_dump, pg_restore)."""
101    env: dict[str, str] = {}
102    if password := config.get("PASSWORD"):
103        env["PGPASSWORD"] = str(password)
104    options = config.get("OPTIONS", {})
105    for option_key, env_var in _CLI_OPTION_ENV_VARS.items():
106        if value := options.get(option_key):
107            env[env_var] = str(value)
108    return env
109
110
111def build_database_url(config: DatabaseConfig) -> str:
112    """Build a database URL from a configuration dictionary."""
113    options = config.get("OPTIONS", {})
114    query = urlparse.urlencode(list(options.items()))
115
116    user = urlparse.quote(str(config.get("USER", "")))
117    password = urlparse.quote(str(config.get("PASSWORD", "")))
118    host = config.get("HOST", "")
119    port = config.get("PORT")
120    name = urlparse.quote(str(config.get("DATABASE", "")))
121
122    netloc = ""
123    if user or password:
124        netloc += user
125        if password:
126            netloc += f":{password}"
127        netloc += "@"
128    netloc += host
129    if port:
130        netloc += f":{port}"
131
132    return urlparse.urlunsplit(("postgresql", netloc, f"/{name}", query, ""))
133
134
135def replace_database_name(url: str, name: str) -> str:
136    """Return the URL with the database name (path segment) replaced.
137
138    Preserves scheme, netloc, query string, and fragment exactly — only the
139    path changes. Avoids the round-trip through parse/build, which normalizes
140    the scheme and collapses duplicate query keys.
141    """
142    spliturl = urlparse.urlsplit(url)
143    return urlparse.urlunsplit(spliturl._replace(path=f"/{urlparse.quote(name)}"))