Skip to content

API Reference

This page is auto-generated from Python docstrings.

pup_core

Inspect package.

base

Base package.

errors

Shared exception types.

GitInspectionError

Bases: PupCoreError

Raised when required Git repository information cannot be inspected.

Source code in src/pup_core/base/errors.py
class GitInspectionError(PupCoreError):
    """Raised when required Git repository information cannot be inspected."""
PupCoreError

Bases: Exception

Base exception for pup-core.

Source code in src/pup_core/base/errors.py
class PupCoreError(Exception):
    """Base exception for pup-core."""
PyprojectError

Bases: PupCoreError

Raised when pyproject.toml cannot be read or interpreted.

Source code in src/pup_core/base/errors.py
class PyprojectError(PupCoreError):
    """Raised when pyproject.toml cannot be read or interpreted."""
RepositoryDetectionError

Bases: PupCoreError

Raised when the target repository cannot be determined.

Source code in src/pup_core/base/errors.py
class RepositoryDetectionError(PupCoreError):
    """Raised when the target repository cannot be determined."""
SectionError

Bases: PupCoreError

Raised when a managed file section is malformed or ambiguous.

Source code in src/pup_core/base/errors.py
class SectionError(PupCoreError):
    """Raised when a managed file section is malformed or ambiguous."""
UnsafePathError

Bases: PupCoreError

Raised when a path would escape the target repository.

Source code in src/pup_core/base/errors.py
class UnsafePathError(PupCoreError):
    """Raised when a path would escape the target repository."""

    def __init__(self, path: Path) -> None:
        """Initialize the error."""
        super().__init__(f"Unsafe path escapes repository root: {path}")
__init__
__init__(path: Path) -> None

Initialize the error.

Source code in src/pup_core/base/errors.py
def __init__(self, path: Path) -> None:
    """Initialize the error."""
    super().__init__(f"Unsafe path escapes repository root: {path}")

types

Shared typed records.

GitInfo dataclass

Detected Git repository information.

Source code in src/pup_core/base/types.py
@dataclass(frozen=True)
class GitInfo:
    """Detected Git repository information."""

    origin_url: str
    github_owner: str
    github_repo: str
    tracked_files: frozenset[str]
    untracked_files: frozenset[str]
    ignored_files: frozenset[str]
    dirty: bool
ManagedSection dataclass

A uniquely identified managed section in file text.

Source code in src/pup_core/base/types.py
@dataclass(frozen=True)
class ManagedSection:
    """A uniquely identified managed section in file text."""

    name: str
    start_marker: str
    end_marker: str
    start_index: int
    end_index: int
    content_start: int
    content_end: int
    content: str
PackageInfo dataclass

Detected Python package information.

Source code in src/pup_core/base/types.py
@dataclass(frozen=True)
class PackageInfo:
    """Detected Python package information."""

    src_layout: bool
    packages: tuple[str, ...]
    primary_package: str
PyprojectInfo dataclass

Selected facts read from pyproject.toml.

Source code in src/pup_core/base/types.py
@dataclass(frozen=True)
class PyprojectInfo:
    """Selected facts read from pyproject.toml."""

    path: Path
    project_name: str
    requires_python: str
    dependencies: tuple[str, ...]
    dependency_groups: dict[str, tuple[str, ...]]
    optional_dependencies: dict[str, tuple[str, ...]]
    scripts: dict[str, str]
    build_backend: str
    uv_default_groups: tuple[str, ...] | Literal["all"] | None
    pyright_python_version: str
    ruff_target_version: str
    pytest_testpaths: tuple[str, ...]
    pytest_addopts: str
    hatch_version_file: str
    hatch_wheel_packages: tuple[str, ...]
RepositoryContext dataclass

Detected information about a target repository.

Source code in src/pup_core/base/types.py
@dataclass(frozen=True)
class RepositoryContext:
    """Detected information about a target repository."""

    root: Path
    github_handle: str
    repo_name: str
    repo_url: str
    site_url: str
    src_package: str
    files: frozenset[str]
SectionPlan dataclass

Planned operation for one explicitly managed section.

Source code in src/pup_core/base/types.py
@dataclass(frozen=True)
class SectionPlan:
    """Planned operation for one explicitly managed section."""

    name: str
    action: SectionAction
    start_marker: str
    end_marker: str
    current: ManagedSection | None
    desired_content: str | None
    insert_before_marker: str | None = None

inspect

Inspect package.

detect

Repository detection.

detect_repository
detect_repository(
    root: Path | None = None,
) -> RepositoryContext

Detect objective facts about a repository.

Source code in src/pup_core/inspect/detect.py
def detect_repository(root: Path | None = None) -> RepositoryContext:
    """Detect objective facts about a repository."""
    repo_root = resolve_repository_root(root)
    files = snapshot_repository_files(repo_root)
    git_info = inspect_git(repo_root)

    github_handle = git_info.github_owner
    repo_name = git_info.github_repo or repo_root.name

    if github_handle:
        repo_url = f"https://github.com/{github_handle}/{repo_name}"
        site_url = f"https://{github_handle}.github.io/{repo_name}/"
    else:
        repo_url = ""
        site_url = ""

    return RepositoryContext(
        root=repo_root,
        github_handle=github_handle,
        repo_name=repo_name,
        repo_url=repo_url,
        site_url=site_url,
        src_package=detect_primary_package(repo_root),
        files=frozenset(files),
    )
resolve_repository_root
resolve_repository_root(root: Path | None = None) -> Path

Resolve the target repository root.

Source code in src/pup_core/inspect/detect.py
def resolve_repository_root(root: Path | None = None) -> Path:
    """Resolve the target repository root."""
    start = Path.cwd() if root is None else root
    start = start.expanduser().resolve()

    if not start.exists():
        raise RepositoryDetectionError(f"Repository path does not exist: {start}")

    if start.is_file():
        start = start.parent

    for candidate in (start, *start.parents):
        if (candidate / ".git").exists():
            return candidate

    return start
snapshot_repository_files
snapshot_repository_files(root: Path) -> set[str]

Return repository-relative file and directory markers.

Source code in src/pup_core/inspect/detect.py
def snapshot_repository_files(root: Path) -> set[str]:
    """Return repository-relative file and directory markers."""
    result: set[str] = set()

    ignored_dirs = {
        ".git",
        ".mypy_cache",
        ".pytest_cache",
        ".ruff_cache",
        ".venv",
        "__pycache__",
        "build",
        "dist",
        "htmlcov",
        "node_modules",
        "site",
    }

    for path in root.rglob("*"):
        relative = path.relative_to(root)

        if any(part in ignored_dirs for part in relative.parts):
            continue

        rel = relative.as_posix()

        if path.is_dir():
            result.add(rel)
            result.add(f"{rel}/")
        else:
            result.add(rel)

    return result

git

Git repository inspection.

inspect_git
inspect_git(root: Path) -> GitInfo

Inspect Git facts without modifying the repository.

Source code in src/pup_core/inspect/git.py
def inspect_git(root: Path) -> GitInfo:
    """Inspect Git facts without modifying the repository."""
    origin_url = _run_git(root, "config", "--get", "remote.origin.url")
    github_owner, github_repo = _parse_github_remote(origin_url)

    tracked_files = _lines(_run_git(root, "ls-files"))
    untracked_files = _lines(
        _run_git(root, "ls-files", "--others", "--exclude-standard")
    )
    ignored_files = _lines(
        _run_git(
            root,
            "ls-files",
            "--others",
            "--ignored",
            "--exclude-standard",
        )
    )

    status = _run_git(root, "status", "--porcelain")

    return GitInfo(
        origin_url=origin_url,
        github_owner=github_owner,
        github_repo=github_repo,
        tracked_files=frozenset(tracked_files),
        untracked_files=frozenset(untracked_files),
        ignored_files=frozenset(ignored_files),
        dirty=bool(status.strip()),
    )

packages

Python package inspection.

detect_packages
detect_packages(root: Path) -> PackageInfo

Detect importable packages in a repository.

Source code in src/pup_core/inspect/packages.py
def detect_packages(root: Path) -> PackageInfo:
    """Detect importable packages in a repository."""
    src = root / "src"

    if src.is_dir():
        packages = _packages_under(src)
        return PackageInfo(
            src_layout=True,
            packages=packages,
            primary_package=_choose_primary_package(packages),
        )

    packages = _packages_under(root)

    return PackageInfo(
        src_layout=False,
        packages=packages,
        primary_package=_choose_primary_package(packages),
    )
detect_primary_package
detect_primary_package(root: Path) -> str

Return the primary detected Python package name.

Source code in src/pup_core/inspect/packages.py
def detect_primary_package(root: Path) -> str:
    """Return the primary detected Python package name."""
    return detect_packages(root).primary_package
module_exists
module_exists(root: Path, module_name: str) -> bool

Return whether a dotted Python module exists in the repository.

Source code in src/pup_core/inspect/packages.py
def module_exists(root: Path, module_name: str) -> bool:
    """Return whether a dotted Python module exists in the repository."""
    relative = Path(*module_name.split("."))

    for base in (root / "src", root):
        package_path = base / relative / "__init__.py"
        module_path = base / relative.with_suffix(".py")

        if package_path.is_file() or module_path.is_file():
            return True

    return False

pyproject

pyproject.toml inspection.

inspect_pyproject
inspect_pyproject(root: Path) -> PyprojectInfo

Return selected project and tooling facts from pyproject.toml.

Source code in src/pup_core/inspect/pyproject.py
def inspect_pyproject(root: Path) -> PyprojectInfo:
    """Return selected project and tooling facts from pyproject.toml."""
    data = load_pyproject(root)

    project = _mapping(data.get("project"))
    dependency_groups = _dependency_mapping(data.get("dependency-groups"))
    optional_dependencies = _dependency_mapping(project.get("optional-dependencies"))

    tool = _mapping(data.get("tool"))
    uv = _mapping(tool.get("uv"))
    pyright = _mapping(tool.get("pyright"))
    ruff = _mapping(tool.get("ruff"))

    pytest = _mapping(tool.get("pytest"))
    pytest_ini = _mapping(pytest.get("ini_options"))

    hatch = _mapping(tool.get("hatch"))
    hatch_build = _mapping(hatch.get("build"))
    hatch_targets = _mapping(hatch_build.get("targets"))
    hatch_wheel = _mapping(hatch_targets.get("wheel"))
    hatch_hooks = _mapping(hatch_build.get("hooks"))
    hatch_vcs_hook = _mapping(hatch_hooks.get("vcs"))

    build_system = _mapping(data.get("build-system"))

    return PyprojectInfo(
        path=root / "pyproject.toml",
        project_name=_string(project.get("name")),
        requires_python=_string(project.get("requires-python")),
        dependencies=_string_tuple(project.get("dependencies")),
        dependency_groups=dependency_groups,
        optional_dependencies=optional_dependencies,
        scripts=_string_mapping(project.get("scripts")),
        build_backend=_string(build_system.get("build-backend")),
        uv_default_groups=_uv_default_groups(uv.get("default-groups")),
        pyright_python_version=_string(pyright.get("pythonVersion")),
        ruff_target_version=_string(ruff.get("target-version")),
        pytest_testpaths=_string_tuple(pytest_ini.get("testpaths")),
        pytest_addopts=_string(pytest_ini.get("addopts")),
        hatch_version_file=_string(hatch_vcs_hook.get("version-file")),
        hatch_wheel_packages=_string_tuple(hatch_wheel.get("packages")),
    )
load_pyproject
load_pyproject(root: Path) -> dict[str, Any]

Load pyproject.toml without modifying it.

Source code in src/pup_core/inspect/pyproject.py
def load_pyproject(root: Path) -> dict[str, Any]:
    """Load pyproject.toml without modifying it."""
    path = root / "pyproject.toml"

    if not path.is_file():
        raise PyprojectError(f"pyproject.toml not found: {path}")

    try:
        with path.open("rb") as file:
            return tomllib.load(file)
    except tomllib.TOMLDecodeError as exc:
        raise PyprojectError(f"Invalid pyproject.toml: {path}") from exc

paths

Paths package.

normalize

Repository path normalization.

normalize_repo_path
normalize_repo_path(path: str | Path) -> str

Return a repository path using normalized POSIX separators.

Source code in src/pup_core/paths/normalize.py
def normalize_repo_path(path: str | Path) -> str:
    """Return a repository path using normalized POSIX separators."""
    value = str(path).replace("\\", "/")
    normalized = PurePosixPath(value)

    if str(normalized) == ".":
        return ""

    return normalized.as_posix()

safe

Safe repository-relative path handling.

safe_repo_path
safe_repo_path(
    root: Path, relative_path: str | Path
) -> Path

Resolve a repository-relative path without allowing escape.

Source code in src/pup_core/paths/safe.py
def safe_repo_path(root: Path, relative_path: str | Path) -> Path:
    """Resolve a repository-relative path without allowing escape."""
    root = root.resolve()
    candidate_input = Path(relative_path)

    if candidate_input.is_absolute():
        raise UnsafePathError(candidate_input)

    candidate = (root / candidate_input).resolve()

    try:
        candidate.relative_to(root)
    except ValueError as exc:
        raise UnsafePathError(candidate) from exc

    return candidate

python

Python package.

names

Python project and package name normalization.

canonical_distribution_name
canonical_distribution_name(name: str) -> str

Return the canonical comparison form of a distribution name.

Source code in src/pup_core/python/names.py
def canonical_distribution_name(name: str) -> str:
    """Return the canonical comparison form of a distribution name."""
    return _SEPARATOR_RE.sub("-", name).lower()
distribution_to_import_name
distribution_to_import_name(name: str) -> str

Convert a distribution name to its conventional import-name form.

Source code in src/pup_core/python/names.py
def distribution_to_import_name(name: str) -> str:
    """Convert a distribution name to its conventional import-name form."""
    return _SEPARATOR_RE.sub("_", name).lower()
normalize_import_name
normalize_import_name(name: str) -> str

Normalize a dotted Python import name for comparison.

Source code in src/pup_core/python/names.py
def normalize_import_name(name: str) -> str:
    """Normalize a dotted Python import name for comparison."""
    return ".".join(
        distribution_to_import_name(part) for part in name.strip().split(".") if part
    )

versions

Python version normalization.

minimum_python_version
minimum_python_version(requires_python: str) -> str

Extract the first major.minor version from requires-python.

Source code in src/pup_core/python/versions.py
def minimum_python_version(requires_python: str) -> str:
    """Extract the first major.minor version from requires-python."""
    return normalize_python_version(requires_python)
normalize_python_version
normalize_python_version(value: str) -> str

Normalize a Python version to major.minor form.

Source code in src/pup_core/python/versions.py
def normalize_python_version(value: str) -> str:
    """Normalize a Python version to major.minor form."""
    match = _VERSION_RE.search(value.strip())

    if match is None:
        return ""

    return f"{match.group('major')}.{match.group('minor')}"
python_version_to_ruff_target
python_version_to_ruff_target(version: str) -> str

Convert a Python major.minor version to Ruff target form.

Source code in src/pup_core/python/versions.py
def python_version_to_ruff_target(version: str) -> str:
    """Convert a Python major.minor version to Ruff target form."""
    normalized = normalize_python_version(version)

    if not normalized:
        return ""

    major, minor = normalized.split(".", maxsplit=1)
    return f"py{major}{minor}"
ruff_target_to_python_version
ruff_target_to_python_version(target: str) -> str

Convert a Ruff target version to Python major.minor form.

Source code in src/pup_core/python/versions.py
def ruff_target_to_python_version(target: str) -> str:
    """Convert a Ruff target version to Python major.minor form."""
    match = _RUFF_RE.fullmatch(target.strip().lower())

    if match is None:
        return ""

    return f"{match.group('major')}.{match.group('minor')}"

sections

Sections package.

apply

Managed section application.

apply_section_plan
apply_section_plan(text: str, plan: SectionPlan) -> str

Apply one previously planned managed-section operation.

Source code in src/pup_core/sections/apply.py
def apply_section_plan(text: str, plan: SectionPlan) -> str:
    """Apply one previously planned managed-section operation."""
    if plan.action == "unchanged":
        return text

    if plan.action == "delete":
        if plan.current is None:
            return text

        return text[: plan.current.start_index] + text[plan.current.end_index :]

    replacement = _render_section(plan)

    if plan.action == "replace":
        if plan.current is None:
            raise SectionError(f"Cannot replace missing managed section {plan.name!r}.")

        return (
            text[: plan.current.start_index]
            + replacement
            + text[plan.current.end_index :]
        )

    if plan.action == "add":
        if plan.current is not None:
            raise SectionError(f"Cannot add existing managed section {plan.name!r}.")

        if plan.insert_before_marker is not None:
            marker_count = text.count(plan.insert_before_marker)

            if marker_count != 1:
                raise SectionError(
                    f"Insertion marker for section {plan.name!r} "
                    "must occur exactly once."
                )

            index = text.index(plan.insert_before_marker)
            return text[:index] + replacement + text[index:]

        if not text:
            return replacement

        separator = "" if text.endswith("\n") else "\n"
        return text + separator + replacement

    raise SectionError(f"Unknown managed-section action: {plan.action!r}")

detect

Managed section detection.

find_managed_section
find_managed_section(
    text: str,
    *,
    name: str,
    start_marker: str,
    end_marker: str,
) -> ManagedSection | None

Find one explicitly bounded managed section.

Source code in src/pup_core/sections/detect.py
def find_managed_section(
    text: str,
    *,
    name: str,
    start_marker: str,
    end_marker: str,
) -> ManagedSection | None:
    """Find one explicitly bounded managed section."""
    start_count = text.count(start_marker)
    end_count = text.count(end_marker)

    if start_count == 0 and end_count == 0:
        return None

    if start_count != 1 or end_count != 1:
        raise SectionError(
            f"Managed section {name!r} must have exactly one "
            "start marker and one end marker."
        )

    start_index = text.index(start_marker)
    content_start = start_index + len(start_marker)
    end_marker_index = text.index(end_marker)

    if end_marker_index < content_start:
        raise SectionError(f"Managed section {name!r} has markers in the wrong order.")

    end_index = end_marker_index + len(end_marker)

    return ManagedSection(
        name=name,
        start_marker=start_marker,
        end_marker=end_marker,
        start_index=start_index,
        end_index=end_index,
        content_start=content_start,
        content_end=end_marker_index,
        content=text[content_start:end_marker_index],
    )

plan

Managed section planning.

plan_section_change
plan_section_change(
    text: str,
    *,
    name: str,
    start_marker: str,
    end_marker: str,
    desired_content: str | None,
    insert_before_marker: str | None = None,
) -> SectionPlan

Plan an add, replace, delete, or unchanged section operation.

Source code in src/pup_core/sections/plan.py
def plan_section_change(
    text: str,
    *,
    name: str,
    start_marker: str,
    end_marker: str,
    desired_content: str | None,
    insert_before_marker: str | None = None,
) -> SectionPlan:
    """Plan an add, replace, delete, or unchanged section operation."""
    current = find_managed_section(
        text,
        name=name,
        start_marker=start_marker,
        end_marker=end_marker,
    )

    if current is None:
        action = "unchanged" if desired_content is None else "add"
    elif desired_content is None:
        action = "delete"
    elif current.content == desired_content:
        action = "unchanged"
    else:
        action = "replace"

    return SectionPlan(
        name=name,
        action=action,
        start_marker=start_marker,
        end_marker=end_marker,
        current=current,
        desired_content=desired_content,
        insert_before_marker=insert_before_marker,
    )