from __future__ import annotations
import shutil
import tarfile
import tomllib
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Protocol
PACKAGE_NAME = "pg-embed-setup-unpriv"
PATH_SEPARATORS = frozenset({"/", "\\"})
class ReleaseArchiveSpecLike(Protocol):
repo: Path
target: str
version: str
dist_dir: Path
binaries: tuple[str, ...]
@dataclass(frozen=True)
class ManifestVersionError(Exception):
manifest_path: Path
reason: str
def __str__(self) -> str:
return f"failed to read package version from {self.manifest_path}: {self.reason}"
def binary_extension(target: str) -> str:
return ".exe" if "windows" in target else ""
def manifest_version(manifest_path: Path) -> str:
data = _load_manifest_data(manifest_path)
return _package_version_from_manifest_data(manifest_path, data)
def _load_manifest_data(manifest_path: Path) -> object:
try:
with manifest_path.open("rb") as manifest:
return tomllib.load(manifest)
except OSError as err:
raise ManifestVersionError(manifest_path, str(err)) from err
except tomllib.TOMLDecodeError as err:
raise ManifestVersionError(manifest_path, f"invalid TOML: {err}") from err
def _package_version_from_manifest_data(manifest_path: Path, data: object) -> str:
if not isinstance(data, Mapping):
raise ManifestVersionError(manifest_path, "manifest must be a table")
try:
package = data["package"]
if not isinstance(package, Mapping):
raise ManifestVersionError(manifest_path, "package must be a table")
version = package["version"]
except KeyError as err:
raise ManifestVersionError(manifest_path, f"missing key: {err}") from err
if not isinstance(version, str):
raise ManifestVersionError(manifest_path, "package.version must be a string")
return version
def archive_stem(target: str, version: str) -> str:
_validate_path_component(target, "target")
_validate_path_component(version, "version")
return f"{PACKAGE_NAME}-{target}-v{version}"
def release_binary_path(repo: Path, target: str, binary: str) -> Path:
_validate_path_component(target, "target")
_validate_path_component(binary, "binary")
return repo / "target" / target / "release" / f"{binary}{binary_extension(target)}"
def validate_release_spec_components(target: str, binaries: tuple[str, ...]) -> None:
_validate_path_component(target, "target")
for binary in binaries:
_validate_path_component(binary, "binary")
def _validate_path_component(value: str, kind: str) -> None:
if message := _path_component_violation(value, kind):
raise SystemExit(message)
def _path_component_violation(value: str, kind: str) -> str | None:
return (
_empty_path_component_violation(value, kind)
or _parent_dir_path_component_violation(value, kind)
or _separator_path_component_violation(value, kind)
)
def _empty_path_component_violation(value: str, kind: str) -> str | None:
if not value:
return f"{kind} cannot be empty"
return None
def _parent_dir_path_component_violation(value: str, kind: str) -> str | None:
if value == ".":
return f"{kind} cannot be '.': {value}"
if value == ".." or ".." in value:
return f"{kind} cannot contain '..': {value}"
return None
def _separator_path_component_violation(value: str, kind: str) -> str | None:
if any(separator in value for separator in PATH_SEPARATORS):
return f"{kind} cannot contain path separators: {value}"
return None
def stage_archive(spec: ReleaseArchiveSpecLike) -> Path:
validate_release_spec_components(spec.target, spec.binaries)
spec.dist_dir.mkdir(parents=True, exist_ok=True)
stem = archive_stem(spec.target, spec.version)
archive_path = spec.dist_dir / f"{stem}.tgz"
archive_path.unlink(missing_ok=True)
with TemporaryDirectory(prefix=f"{stem}-") as tmp:
staging_root = Path(tmp) / stem
staging_root.mkdir()
copy_release_binaries(spec.repo, spec.target, spec.binaries, staging_root)
with tarfile.open(archive_path, "w:gz", format=tarfile.PAX_FORMAT) as archive:
archive.add(staging_root, arcname=stem)
return archive_path
def copy_release_binaries(
repo: Path,
target: str,
binaries: tuple[str, ...],
staging_root: Path,
) -> None:
for binary in binaries:
source = release_binary_path(repo, target, binary)
if not source.is_file():
raise FileNotFoundError(f"release binary missing: {source}")
shutil.copy2(source, staging_root / source.name)